diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp
index ebf57d2f4c..876e8a0e59 100644
--- a/src/ifcviewer-web/main_web.cpp
+++ b/src/ifcviewer-web/main_web.cpp
@@ -253,6 +253,10 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) {
app->nav_drag_px = 0.0f;
app->down_x = e->targetX; // canvas-relative CSS px
app->down_y = e->targetY;
+ // Show the pivot triad for the duration of an orbit / pan drag, so
+ // it's visible what the camera turns around (matches the desktop).
+ if (kind == NavKind::Orbit || kind == NavKind::Pan)
+ app->core.setPivotIndicatorVisible(true);
}
return EM_TRUE;
}
@@ -297,6 +301,10 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
const NavKind kind = app->nav_kind;
app->nav_active = false;
app->nav_kind = NavKind::None;
+ // Drag is over — hide the pivot indicator without afterglow. Only for the
+ // gesture that raised it; a stray mouseup must not cut a wheel afterglow.
+ if (was_active && (kind == NavKind::Orbit || kind == NavKind::Pan))
+ app->core.setPivotIndicatorVisible(false);
// End a section-gizmo drag (took over the press; no pick/orbit on release).
if (app->section_dragging) {
@@ -373,6 +381,9 @@ EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) {
// In fly mode the wheel tunes move speed (Blender convention), not zoom.
if (app->fly_mode) { app->core.flyAdjustSpeed(-float(dy) / 100.0f); return EM_TRUE; }
app->core.dollyBy(-float(dy) / 100.0f);
+ // Pivot afterglow on wheel — visible for 600 ms so the user can see what
+ // they're zooming around without holding a drag.
+ app->core.setPivotIndicatorVisible(true, 600);
return EM_TRUE; // consume so the page doesn't scroll
}
diff --git a/src/ifcviewer-web/tests/axis.spec.mjs b/src/ifcviewer-web/tests/axis.spec.mjs
new file mode 100644
index 0000000000..9fb9409204
--- /dev/null
+++ b/src/ifcviewer-web/tests/axis.spec.mjs
@@ -0,0 +1,99 @@
+// This file was generated with the assistance of an AI coding tool.
+//
+// The RGB axis indicator, in both of its guises: the corner gizmo that sits
+// in the viewport's bottom-left, and the pivot triad that appears at the
+// orbit target while a navigation drag is running. Both are drawn by the
+// shared AxisIndicatorRenderer from ViewportCore, so a regression here would
+// most likely be a wiring one — the renderer never inited, the pivot gate
+// never set, the corner pass encoded before the surface resolved — none of
+// which any other test in the suite would notice.
+import { test, expect } from '@playwright/test';
+import zlib from 'node:zlib';
+
+// Decode the top-left pixel (RGB) of a PNG buffer. Row 0 pixel 0 is
+// filter-agnostic — every PNG predictor references zero neighbours there —
+// so this can skip filter handling entirely.
+function firstPixelRGB(png) {
+ let off = 8;
+ const idat = [];
+ while (off + 8 <= png.length) {
+ const len = png.readUInt32BE(off);
+ const type = png.toString('ascii', off + 4, off + 8);
+ const data = png.subarray(off + 8, off + 8 + len);
+ if (type === 'IDAT') idat.push(data);
+ else if (type === 'IEND') break;
+ off += 12 + len;
+ }
+ const raw = zlib.inflateSync(Buffer.concat(idat));
+ return [raw[1], raw[2], raw[3]]; // skip the row filter byte
+}
+
+// Is this pixel on the +Z arm? Its colour is Bonsai's decorator blue
+// (0.157, 0.565, 1.000), so blue leads red by a mile. Everything it can be
+// drawn over stays well under the threshold: the background is a near-grey
+// (32, 35, 41), the sample model is white, and even the dim x-ray pass —
+// 0.3 alpha where the arm is behind geometry — lands around (191, 222, 255).
+const isAxisBlue = ([r, , b]) => b - r > 30;
+
+// Sample 1x1 pixels straight up from (cx, cy), which is where the +Z arm
+// points at the default camera pitch. Stepping rather than picking one exact
+// pixel keeps this off the anti-aliased edges of a 2.5 px line.
+async function scanUp(page, cx, cy, from, to, step = 4) {
+ const hits = [];
+ for (let dy = from; dy <= to; dy += step) {
+ const png = await page.screenshot({
+ clip: { x: Math.round(cx), y: Math.round(cy - dy), width: 1, height: 1 },
+ });
+ hits.push(firstPixelRGB(png));
+ }
+ return hits;
+}
+
+async function boot(page) {
+ await page.goto('/IfcViewerWeb.html');
+ await page.waitForFunction(
+ () => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
+ await page.waitForTimeout(1200);
+ return page.locator('#viewer-canvas').boundingBox();
+}
+
+test('corner axis gizmo draws in the bottom-left', async ({ page }) => {
+ const box = await boot(page);
+ // Gizmo box: 110 CSS px square, 10 px in from the bottom-left corner. The
+ // +Z arm runs up from its centre for ~39 px (arm 1.0 in a 1.4 half-extent
+ // ortho, over a 55 px half-box).
+ const cx = box.x + 10 + 55;
+ const cy = box.y + box.height - 10 - 55;
+ const hits = await scanUp(page, cx, cy, 10, 34);
+ expect(
+ hits.some(isAxisBlue),
+ `no +Z arm above the gizmo centre — corner axis missing (sampled ${JSON.stringify(hits)})`,
+ ).toBe(true);
+});
+
+test('pivot triad shows during an orbit drag and clears on release', async ({ page }) => {
+ const box = await boot(page);
+ // The orbit target projects to the viewport centre, and the pivot arms are
+ // 30 CSS px, so the +Z arm runs up from there.
+ const cx = box.x + box.width / 2;
+ const cy = box.y + box.height / 2;
+
+ const before = await scanUp(page, cx, cy, 8, 26);
+ expect(before.some(isAxisBlue), 'pivot visible before any drag').toBe(false);
+
+ await page.mouse.move(cx, cy);
+ await page.mouse.down();
+ await page.mouse.move(cx + 90, cy + 30, { steps: 8 });
+ await page.waitForTimeout(200);
+ const during = await scanUp(page, cx, cy, 8, 26);
+ await page.mouse.up();
+ expect(
+ during.some(isAxisBlue),
+ `no pivot triad mid-drag (sampled ${JSON.stringify(during)})`,
+ ).toBe(true);
+
+ // Released without afterglow — the indicator goes on the next frame.
+ await page.waitForTimeout(400);
+ const after = await scanUp(page, cx, cy, 8, 26);
+ expect(after.some(isAxisBlue), 'pivot triad still up after mouse release').toBe(false);
+});
diff --git a/src/ifcviewer-web/web/IfcViewerWeb.html b/src/ifcviewer-web/web/IfcViewerWeb.html
index 8ac7799053..8ade29630a 100644
--- a/src/ifcviewer-web/web/IfcViewerWeb.html
+++ b/src/ifcviewer-web/web/IfcViewerWeb.html
@@ -12,8 +12,10 @@
eats pointer events so the drag keeps reaching the canvas. */
#marquee { position: fixed; display: none; z-index: 50; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
- /* Log overlay sits bottom-left and never eats pointer events. */
- #status { position: fixed; bottom: 8px; left: 12px;
+ /* Log overlay sits bottom-left and never eats pointer events. Kept clear
+ of the corner axis gizmo, which the viewport draws in the bottom-left
+ 110 CSS px (plus a 10 px margin). */
+ #status { position: fixed; bottom: 8px; left: 132px;
max-width: min(60vw, 680px); max-height: 28vh; overflow-y: auto;
font-size: 11px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
diff --git a/src/ifcviewer/AxisIndicatorRenderer.cpp b/src/ifcviewer/AxisIndicatorRenderer.cpp
new file mode 100644
index 0000000000..c56eb068e2
--- /dev/null
+++ b/src/ifcviewer/AxisIndicatorRenderer.cpp
@@ -0,0 +1,423 @@
+/********************************************************************************
+ * *
+ * 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 . *
+ * *
+ ********************************************************************************/
+
+#include "AxisIndicatorRenderer.h"
+
+#include
+#include
+#include
+#include
+
+#include "CameraMath.h"
+
+namespace {
+
+constexpr uint32_t kAxisUniformSlot = 256; // dynamic-offset slot stride
+constexpr uint32_t kAxisVertexCount = 18; // 3 arms x 2 triangles x 3 verts
+
+// Uniform slots in the shared buffer.
+constexpr uint32_t kSlotCorner = 0;
+constexpr uint32_t kSlotPivot = 1;
+constexpr uint32_t kSlotPivotXray = 2;
+
+WGPUStringView svFromCStr(const char* s) {
+ WGPUStringView v;
+ v.data = s;
+ v.length = s ? std::strlen(s) : 0;
+ return v;
+}
+
+// Thick-line rendering helper (shared shape with the other overlays) + the
+// axis vertex shader. Each arm is expanded to a screen-space-thick,
+// anti-aliased quad.
+static const std::string AXIS_WGSL = std::string(R"WGSL(
+struct VsOut {
+ @builtin(position) clip_pos: vec4,
+ @location(0) color: vec4,
+ @location(1) side_t: f32,
+};
+
+fn thick_line_clip(p_start: vec4, p_end: vec4,
+ t: f32, side: f32,
+ viewport_size: vec2,
+ line_width_px: f32) -> vec4 {
+ let p_here = mix(p_start, p_end, t);
+ let s_start = (p_start.xy / p_start.w) * viewport_size * 0.5;
+ let s_end = (p_end.xy / p_end.w ) * viewport_size * 0.5;
+ let dir = normalize(s_end - s_start);
+ let perp = vec2(-dir.y, dir.x);
+ let off_pixels = perp * (line_width_px * 0.5) * side;
+ let off_ndc = off_pixels * 2.0 / viewport_size;
+ return vec4(p_here.xy + off_ndc * p_here.w, p_here.zw);
+}
+
+@fragment
+fn fs_main(in: VsOut) -> @location(0) vec4 {
+ let d = abs(in.side_t);
+ let aa = fwidth(in.side_t);
+ let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
+ return vec4(in.color.xyz, in.color.w * coverage);
+}
+
+struct AxisUniforms {
+ mvp: mat4x4,
+ origin: vec3,
+ arm: f32,
+ alpha: f32,
+ line_width_px: f32,
+ viewport_size: vec2,
+};
+
+@group(0) @binding(0) var u: AxisUniforms;
+
+@vertex
+fn vs_main(@location(0) start: vec3,
+ @location(1) end: vec3,
+ @location(2) col: vec3,
+ @location(3) t: f32,
+ @location(4) side: f32) -> VsOut {
+ let p_start = u.mvp * vec4(u.origin + start * u.arm, 1.0);
+ let p_end = u.mvp * vec4(u.origin + end * u.arm, 1.0);
+ var out: VsOut;
+ out.clip_pos = thick_line_clip(p_start, p_end, t, side,
+ u.viewport_size, u.line_width_px);
+ out.color = vec4(col, u.alpha);
+ out.side_t = side;
+ return out;
+}
+)WGSL");
+
+// Pack the axis uniform's 256-byte slot. Layout matches WGSL AxisUniforms:
+// mat4 + vec3 + f32 + f32 + f32 + vec2 = 96 B used, padded to 256.
+void packAxisUniform(uint8_t* dst,
+ const Eigen::Matrix4f& mvp, const Eigen::Vector3f& origin,
+ float arm, float alpha, float line_width_px,
+ float viewport_w, float viewport_h) {
+ std::memset(dst, 0, kAxisUniformSlot);
+ std::memcpy(dst, mvp.data(), 16 * sizeof(float));
+ float ox = origin.x(), oy = origin.y(), oz = origin.z();
+ std::memcpy(dst + 64, &ox, sizeof(float));
+ std::memcpy(dst + 68, &oy, sizeof(float));
+ std::memcpy(dst + 72, &oz, sizeof(float));
+ std::memcpy(dst + 76, &arm, sizeof(float));
+ std::memcpy(dst + 80, &alpha, sizeof(float));
+ std::memcpy(dst + 84, &line_width_px, sizeof(float));
+ std::memcpy(dst + 88, &viewport_w, sizeof(float));
+ std::memcpy(dst + 92, &viewport_h, sizeof(float));
+}
+
+} // namespace
+
+AxisIndicatorRenderer::~AxisIndicatorRenderer() { destroy(); }
+
+bool AxisIndicatorRenderer::init(WGPUDevice device, WGPUQueue queue,
+ WGPUTextureFormat color_format, int sample_count) {
+ device_ = device;
+ queue_ = queue;
+ if (!device_ || !queue_) return false;
+
+ // Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
+ // decorator_color_error = (1.000, 0.200, 0.322) — red → +X
+ // decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
+ // decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
+ // Same palette is reused for the section gizmo + marquee so all overlay
+ // colours come from one canonical source.
+ static const float axis_verts[] = {
+ // start end color (RGB — Bonsai decorators) t side
+ // ---- +X red ----
+ 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
+ 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
+ 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
+ 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
+ 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
+ 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
+ // ---- +Y green ----
+ 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
+ 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
+ 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
+ 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
+ 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
+ 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
+ // ---- +Z blue ----
+ 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
+ 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
+ 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
+ 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
+ 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
+ 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
+ };
+
+ WGPUBufferDescriptor vb = {};
+ vb.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
+ vb.size = sizeof(axis_verts);
+ vb.label = svFromCStr("ifcviewer-wgpu.axis_vbo");
+ vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &vb);
+ wgpuQueueWriteBuffer(queue_, vertex_buffer_, 0, axis_verts, sizeof(axis_verts));
+
+ WGPUBufferDescriptor ub = {};
+ ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
+ ub.size = 3u * kAxisUniformSlot;
+ ub.label = svFromCStr("ifcviewer-wgpu.axis_uniforms");
+ uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub);
+
+ WGPUBindGroupLayoutEntry ble = {};
+ ble.binding = 0;
+ ble.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
+ ble.buffer.type = WGPUBufferBindingType_Uniform;
+ ble.buffer.hasDynamicOffset = 1;
+ ble.buffer.minBindingSize = 96;
+ WGPUBindGroupLayoutDescriptor bgl_desc = {};
+ bgl_desc.entryCount = 1;
+ bgl_desc.entries = &ble;
+ bgl_desc.label = svFromCStr("ifcviewer-wgpu.axis_bgl");
+ bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
+
+ WGPUPipelineLayoutDescriptor pl_desc = {};
+ pl_desc.bindGroupLayoutCount = 1;
+ pl_desc.bindGroupLayouts = &bgl_;
+ pl_desc.label = svFromCStr("ifcviewer-wgpu.axis_pipeline_layout");
+ layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
+
+ WGPUBindGroupEntry bge = {};
+ bge.binding = 0;
+ bge.buffer = uniform_buffer_;
+ bge.offset = 0;
+ bge.size = kAxisUniformSlot;
+ WGPUBindGroupDescriptor bg_desc = {};
+ bg_desc.layout = bgl_;
+ bg_desc.entryCount = 1;
+ bg_desc.entries = &bge;
+ bg_desc.label = svFromCStr("ifcviewer-wgpu.axis_bind_group");
+ bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
+
+ WGPUShaderSourceWGSL wgsl = {};
+ wgsl.chain.sType = WGPUSType_ShaderSourceWGSL;
+ wgsl.code = svFromCStr(AXIS_WGSL.c_str());
+ WGPUShaderModuleDescriptor sm_desc = {};
+ sm_desc.nextInChain = &wgsl.chain;
+ sm_desc.label = svFromCStr("ifcviewer-wgpu.axis_wgsl");
+ shader_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
+
+ // Vertex layout: start vec3, end vec3, col vec3, t f32, side f32.
+ WGPUVertexAttribute attribs[5] = {};
+ attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
+ attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
+ attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
+ attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
+ attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
+ WGPUVertexBufferLayout vbl = {};
+ vbl.arrayStride = 44;
+ vbl.stepMode = WGPUVertexStepMode_Vertex;
+ vbl.attributeCount = 5;
+ vbl.attributes = attribs;
+
+ WGPUBlendState blend = {};
+ blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
+ blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
+ blend.color.operation = WGPUBlendOperation_Add;
+ blend.alpha.srcFactor = WGPUBlendFactor_One;
+ blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
+ blend.alpha.operation = WGPUBlendOperation_Add;
+
+ // Pivot: inside the main MSAA pass, depth-tested against the scene but
+ // never writing depth. Two passes — LessEqual for the visible part,
+ // GreaterEqual for the dim x-ray showing through geometry.
+ auto build_pivot = [&](WGPUCompareFunction cmp, const char* label,
+ WGPURenderPipeline& out) {
+ WGPUColorTargetState ct = {};
+ ct.format = color_format;
+ ct.blend = &blend;
+ ct.writeMask = WGPUColorWriteMask_All;
+
+ WGPUFragmentState frag = {};
+ frag.module = shader_;
+ frag.entryPoint = svFromCStr("fs_main");
+ frag.targetCount = 1;
+ frag.targets = &ct;
+
+ WGPUDepthStencilState depth = {};
+ depth.format = WGPUTextureFormat_Depth32Float;
+ depth.depthWriteEnabled = WGPUOptionalBool_False;
+ depth.depthCompare = cmp;
+ depth.stencilFront.compare = WGPUCompareFunction_Always;
+ depth.stencilBack.compare = WGPUCompareFunction_Always;
+
+ WGPURenderPipelineDescriptor rp = {};
+ rp.layout = layout_;
+ rp.label = svFromCStr(label);
+ rp.vertex.module = shader_;
+ rp.vertex.entryPoint = svFromCStr("vs_main");
+ rp.vertex.bufferCount = 1;
+ rp.vertex.buffers = &vbl;
+ rp.fragment = &frag;
+ rp.depthStencil = &depth;
+ rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
+ rp.primitive.cullMode = WGPUCullMode_None;
+ rp.multisample.count = uint32_t(sample_count);
+ rp.multisample.mask = 0xFFFFFFFFu;
+ out = wgpuDeviceCreateRenderPipeline(device_, &rp);
+ };
+ build_pivot(WGPUCompareFunction_LessEqual,
+ "ifcviewer-wgpu.axis_pivot_pipeline", pivot_pipeline_);
+ build_pivot(WGPUCompareFunction_GreaterEqual,
+ "ifcviewer-wgpu.axis_pivot_xray_pipeline", pivot_xray_pipeline_);
+
+ // Corner: resolved surface, no depth, sampleCount=1.
+ {
+ WGPUColorTargetState ct = {};
+ ct.format = color_format;
+ ct.blend = &blend;
+ ct.writeMask = WGPUColorWriteMask_All;
+
+ WGPUFragmentState frag = {};
+ frag.module = shader_;
+ frag.entryPoint = svFromCStr("fs_main");
+ frag.targetCount = 1;
+ frag.targets = &ct;
+
+ WGPURenderPipelineDescriptor rp = {};
+ rp.layout = layout_;
+ rp.label = svFromCStr("ifcviewer-wgpu.axis_corner_pipeline");
+ rp.vertex.module = shader_;
+ rp.vertex.entryPoint = svFromCStr("vs_main");
+ rp.vertex.bufferCount = 1;
+ rp.vertex.buffers = &vbl;
+ rp.fragment = &frag;
+ rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
+ rp.primitive.cullMode = WGPUCullMode_None;
+ rp.multisample.count = 1;
+ rp.multisample.mask = 0xFFFFFFFFu;
+ corner_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp);
+ }
+
+ return pivot_pipeline_ && pivot_xray_pipeline_ && corner_pipeline_;
+}
+
+void AxisIndicatorRenderer::encodePivot(WGPURenderPassEncoder pass,
+ const OverlayFrame& f, bool visible) {
+ if (!visible || !pivot_pipeline_ || !pivot_xray_pipeline_) return;
+ if (f.viewport_h_px <= 0) return;
+
+ // Arm length = 30 logical px projected into world at the pivot's distance.
+ const float fovy_rad = f.camera_fov_y_deg * kPiF / 180.0f;
+ const float world_per_pixel = f.camera_distance * std::tan(fovy_rad * 0.5f)
+ * 2.0f / float(f.viewport_h_px);
+ const float arm_pixels = 30.0f * float(f.device_pixel_ratio);
+ const float arm_world = arm_pixels * world_per_pixel;
+
+ const float dpr = float(f.device_pixel_ratio);
+ const float line_w = 2.5f * dpr;
+ const float vw = float(f.viewport_w_px);
+ const float vh = float(f.viewport_h_px);
+
+ uint8_t slot_visible[kAxisUniformSlot];
+ uint8_t slot_xray[kAxisUniformSlot];
+ packAxisUniform(slot_visible, f.view_proj, f.camera_target, arm_world,
+ 1.00f, line_w, vw, vh);
+ packAxisUniform(slot_xray, f.view_proj, f.camera_target, arm_world,
+ 0.30f, line_w, vw, vh);
+ const uint32_t visible_off = kSlotPivot * kAxisUniformSlot;
+ const uint32_t xray_off = kSlotPivotXray * kAxisUniformSlot;
+ wgpuQueueWriteBuffer(queue_, uniform_buffer_, visible_off,
+ slot_visible, sizeof(slot_visible));
+ wgpuQueueWriteBuffer(queue_, uniform_buffer_, xray_off,
+ slot_xray, sizeof(slot_xray));
+
+ wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
+ wgpuRenderPassEncoderSetPipeline(pass, pivot_xray_pipeline_);
+ wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &xray_off);
+ wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
+ wgpuRenderPassEncoderSetPipeline(pass, pivot_pipeline_);
+ wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &visible_off);
+ wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
+}
+
+void AxisIndicatorRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
+ WGPUTextureView surface_view,
+ const OverlayFrame& f) {
+ if (!corner_pipeline_ || !surface_view) return;
+ const int dpr = std::max(1, f.device_pixel_ratio);
+ const uint32_t gizmo_size = uint32_t(110 * dpr);
+ const uint32_t margin = uint32_t(10 * dpr);
+ if (gizmo_size == 0 || f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
+ // Bottom-left in WebGPU framebuffer space (y down).
+ const uint32_t fb_h = uint32_t(f.viewport_h_px);
+ if (gizmo_size + margin > fb_h) return;
+ const uint32_t y = fb_h - margin - gizmo_size;
+
+ // Independent ortho projection from the camera's direction. Near the
+ // poles the up axis collapses against the look direction, so swap to
+ // Y-up there — mirrors buildViewProj's identical fix on the viewport.
+ const float yaw_rad = f.camera_yaw_deg * kPiF / 180.0f;
+ const float pitch_rad = f.camera_pitch_deg * kPiF / 180.0f;
+ const Eigen::Vector3f eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
+ std::cos(pitch_rad) * std::sin(yaw_rad),
+ std::sin(pitch_rad));
+ const Eigen::Vector3f world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
+ ? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
+ : Eigen::Vector3f(0.0f, 0.0f, 1.0f);
+ const Eigen::Matrix4f gv = lookAtRH(eye_dir * 3.0f, Eigen::Vector3f::Zero(), world_up);
+ const Eigen::Matrix4f gp = orthoGL(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
+ Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
+ z_remap(2, 2) = 0.5f;
+ z_remap(2, 3) = 0.5f;
+ const Eigen::Matrix4f mvp = z_remap * gp * gv;
+
+ uint8_t slot[kAxisUniformSlot];
+ const float line_w = 2.5f * float(dpr);
+ packAxisUniform(slot, mvp, Eigen::Vector3f(0, 0, 0), 1.0f, 1.0f, line_w,
+ float(gizmo_size), float(gizmo_size));
+ const uint32_t slot_offset = kSlotCorner * kAxisUniformSlot;
+ wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
+
+ WGPURenderPassColorAttachment color = {};
+ color.view = surface_view;
+ color.loadOp = WGPULoadOp_Load;
+ color.storeOp = WGPUStoreOp_Store;
+ color.clearValue = { 0.0, 0.0, 0.0, 1.0 };
+ color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
+
+ WGPURenderPassDescriptor pass_desc = {};
+ pass_desc.colorAttachmentCount = 1;
+ pass_desc.colorAttachments = &color;
+ pass_desc.label = svFromCStr("ifcviewer-wgpu.corner_axis_pass");
+
+ WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
+ wgpuRenderPassEncoderSetViewport(pass, float(margin), float(y),
+ float(gizmo_size), float(gizmo_size),
+ 0.0f, 1.0f);
+ wgpuRenderPassEncoderSetPipeline(pass, corner_pipeline_);
+ wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
+ wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
+ wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
+ wgpuRenderPassEncoderEnd(pass);
+ wgpuRenderPassEncoderRelease(pass);
+}
+
+void AxisIndicatorRenderer::destroy() {
+ if (pivot_pipeline_) { wgpuRenderPipelineRelease(pivot_pipeline_); pivot_pipeline_ = nullptr; }
+ if (pivot_xray_pipeline_) { wgpuRenderPipelineRelease(pivot_xray_pipeline_); pivot_xray_pipeline_ = nullptr; }
+ if (corner_pipeline_) { wgpuRenderPipelineRelease(corner_pipeline_); corner_pipeline_ = nullptr; }
+ if (layout_) { wgpuPipelineLayoutRelease(layout_); layout_ = nullptr; }
+ if (bgl_) { wgpuBindGroupLayoutRelease(bgl_); bgl_ = nullptr; }
+ if (bind_group_) { wgpuBindGroupRelease(bind_group_); bind_group_ = nullptr; }
+ if (vertex_buffer_) { wgpuBufferRelease(vertex_buffer_); vertex_buffer_ = nullptr; }
+ if (uniform_buffer_) { wgpuBufferRelease(uniform_buffer_); uniform_buffer_ = nullptr; }
+ if (shader_) { wgpuShaderModuleRelease(shader_); shader_ = nullptr; }
+}
diff --git a/src/ifcviewer/AxisIndicatorRenderer.h b/src/ifcviewer/AxisIndicatorRenderer.h
new file mode 100644
index 0000000000..ba0968e578
--- /dev/null
+++ b/src/ifcviewer/AxisIndicatorRenderer.h
@@ -0,0 +1,84 @@
+/********************************************************************************
+ * *
+ * 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 . *
+ * *
+ ********************************************************************************/
+
+#ifndef AXISINDICATORRENDERER_H
+#define AXISINDICATORRENDERER_H
+
+#include
+
+#include
+
+#include "OverlayFrame.h"
+
+// Qt-free renderer for the RGB axis indicator, in its two guises:
+//
+// - the corner gizmo: a fixed 110x110 px triad in the viewport's
+// bottom-left corner, drawn on the resolved surface with its own ortho
+// projection so only the camera's direction moves it;
+// - the pivot indicator: the same triad drawn in world space at the orbit
+// target while the user is navigating, depth-tested against the scene
+// with a dim x-ray pass behind it.
+//
+// Lifted out of the Qt-coupled OverlayRenderer so BOTH the desktop and web
+// builds draw one identical indicator from a single place (ViewportCore::render
+// calls it on both) — same move SectionGizmoRenderer made.
+class AxisIndicatorRenderer {
+public:
+ AxisIndicatorRenderer() = default;
+ ~AxisIndicatorRenderer();
+ AxisIndicatorRenderer(const AxisIndicatorRenderer&) = delete;
+ AxisIndicatorRenderer& operator=(const AxisIndicatorRenderer&) = delete;
+
+ // Create the shared triad VBO, the uniform buffer (three dynamic-offset
+ // slots: corner / pivot / pivot-xray), and the three pipelines.
+ // `color_format` is the render target's format; `sample_count` the MSAA
+ // count of the main pass the pivot draws into (the corner gizmo always
+ // targets the resolved, single-sampled surface). Returns false — and
+ // leaves the renderer inert — if pipeline creation fails.
+ bool init(WGPUDevice device, WGPUQueue queue,
+ WGPUTextureFormat color_format, int sample_count);
+ void destroy();
+ bool ready() const { return corner_pipeline_ != nullptr; }
+
+ // Orbit pivot indicator, drawn into the already-open main MSAA pass so it
+ // shares depth with the scene. `visible` is the viewport's UI gate (orbit /
+ // pan drag, wheel-zoom afterglow); when false this is a cheap no-op.
+ void encodePivot(WGPURenderPassEncoder pass, const OverlayFrame& f,
+ bool visible);
+
+ // Corner axis gizmo (bottom-left, 110x110 px). Opens its own load-op pass
+ // on the resolved surface, so it must run after the main pass has resolved.
+ void encodeCornerAxis(WGPUCommandEncoder enc, WGPUTextureView surface_view,
+ const OverlayFrame& f);
+
+private:
+ WGPUDevice device_ = nullptr;
+ WGPUQueue queue_ = nullptr;
+ WGPUShaderModule shader_ = nullptr;
+ WGPUBindGroupLayout bgl_ = nullptr;
+ WGPUPipelineLayout layout_ = nullptr;
+ WGPUBindGroup bind_group_ = nullptr;
+ WGPUBuffer vertex_buffer_ = nullptr;
+ WGPUBuffer uniform_buffer_ = nullptr;
+ WGPURenderPipeline pivot_pipeline_ = nullptr;
+ WGPURenderPipeline pivot_xray_pipeline_ = nullptr;
+ WGPURenderPipeline corner_pipeline_ = nullptr;
+};
+
+#endif // AXISINDICATORRENDERER_H
diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt
index d898b8ae3f..897f19d35a 100644
--- a/src/ifcviewer/CMakeLists.txt
+++ b/src/ifcviewer/CMakeLists.txt
@@ -139,6 +139,7 @@ endif()
#
# Keep this list explicit (no glob) — the boundary is the whole point.
set(IFCVIEWER_CORE_SOURCES
+ AxisIndicatorRenderer.cpp
BufferPool.cpp
ChunkPlanner.cpp
InstanceCompose.cpp
@@ -187,6 +188,7 @@ if(EMSCRIPTEN)
# below which would mangle these absolute paths; added via target_sources.
endif()
set(IFCVIEWER_CORE_HEADERS
+ AxisIndicatorRenderer.h
BufferPool.h
CameraMath.h
ChunkPlanner.h
diff --git a/src/ifcviewer/OverlayRenderer.cpp b/src/ifcviewer/OverlayRenderer.cpp
index 1afb8d32a4..674eb51cba 100644
--- a/src/ifcviewer/OverlayRenderer.cpp
+++ b/src/ifcviewer/OverlayRenderer.cpp
@@ -19,15 +19,12 @@
#include "OverlayRenderer.h"
-#include "CameraMath.h"
-
#include
#include
#include
#include
#include
#include
-#include
#include
#include
@@ -49,44 +46,6 @@ WGPUStringView svFromCStr(const char* s) {
return v;
}
-// Populate `attribs[5]` with the standard thick-line vertex layout:
-// loc 0: start (vec3 @ 0) loc 1: end (vec3 @ 12)
-// loc 2: col (vec3 @ 24) loc 3: t (f32 @ 36)
-// loc 4: side (f32 @ 40)
-// Returns a WGPUVertexBufferLayout aliasing the caller-owned `attribs`.
-WGPUVertexBufferLayout thickLineVertexLayout(WGPUVertexAttribute attribs[5]) {
- attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
- attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
- attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
- attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
- attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
- WGPUVertexBufferLayout vbl = {};
- vbl.arrayStride = 44;
- vbl.stepMode = WGPUVertexStepMode_Vertex;
- vbl.attributeCount = 5;
- vbl.attributes = attribs;
- return vbl;
-}
-
-// Pack the axis uniform's 256-byte slot. Layout matches WGSL AxisUniforms:
-// mat4 + vec3 + f32 + f32 + f32 + vec2 = 96 B used, padded to 256.
-void packAxisUniform(uint8_t* dst,
- const Eigen::Matrix4f& mvp, const Eigen::Vector3f& origin,
- float arm, float alpha, float line_width_px,
- float viewport_w, float viewport_h) {
- std::memset(dst, 0, 256);
- std::memcpy(dst, mvp.data(), 16 * sizeof(float));
- float ox = origin.x(), oy = origin.y(), oz = origin.z();
- std::memcpy(dst + 64, &ox, sizeof(float));
- std::memcpy(dst + 68, &oy, sizeof(float));
- std::memcpy(dst + 72, &oz, sizeof(float));
- std::memcpy(dst + 76, &arm, sizeof(float));
- std::memcpy(dst + 80, &alpha, sizeof(float));
- std::memcpy(dst + 84, &line_width_px, sizeof(float));
- std::memcpy(dst + 88, &viewport_w, sizeof(float));
- std::memcpy(dst + 92, &viewport_h, sizeof(float));
-}
-
} // namespace
// -----------------------------------------------------------------------------
@@ -126,35 +85,6 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
}
)WGSL";
-static const std::string AXIS_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
-struct AxisUniforms {
- mvp: mat4x4,
- origin: vec3,
- arm: f32,
- alpha: f32,
- line_width_px: f32,
- viewport_size: vec2,
-};
-
-@group(0) @binding(0) var u: AxisUniforms;
-
-@vertex
-fn vs_main(@location(0) start: vec3,
- @location(1) end: vec3,
- @location(2) col: vec3,
- @location(3) t: f32,
- @location(4) side: f32) -> VsOut {
- let p_start = u.mvp * vec4(u.origin + start * u.arm, 1.0);
- let p_end = u.mvp * vec4(u.origin + end * u.arm, 1.0);
- var out: VsOut;
- out.clip_pos = thick_line_clip(p_start, p_end, t, side,
- u.viewport_size, u.line_width_px);
- out.color = vec4(col, u.alpha);
- out.side_t = side;
- return out;
-}
-)WGSL";
-
static const std::string MARQUEE_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct MarqueeUniforms {
rect_min: vec2,
@@ -383,7 +313,6 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
queue_ = queue;
surface_format_ = surface_format;
sample_count_ = sample_count;
- if (!buildAxisIndicator()) return false;
// Section-plane gizmos moved to the shared SectionGizmoRenderer (ViewportCore).
if (!buildMarquee()) return false;
if (!buildOverlayLines()) return false;
@@ -394,17 +323,6 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
}
void OverlayRenderer::destroy() {
- // Axis indicator
- if (axis_bind_group_) { wgpuBindGroupRelease(axis_bind_group_); axis_bind_group_ = nullptr; }
- if (axis_pivot_pipeline_) { wgpuRenderPipelineRelease(axis_pivot_pipeline_); axis_pivot_pipeline_ = nullptr; }
- if (axis_pivot_xray_pipeline_){ wgpuRenderPipelineRelease(axis_pivot_xray_pipeline_); axis_pivot_xray_pipeline_ = nullptr; }
- if (axis_corner_pipeline_) { wgpuRenderPipelineRelease(axis_corner_pipeline_); axis_corner_pipeline_ = nullptr; }
- if (axis_shader_module_) { wgpuShaderModuleRelease(axis_shader_module_); axis_shader_module_ = nullptr; }
- if (axis_pipeline_layout_) { wgpuPipelineLayoutRelease(axis_pipeline_layout_); axis_pipeline_layout_ = nullptr; }
- if (axis_bgl_) { wgpuBindGroupLayoutRelease(axis_bgl_); axis_bgl_ = nullptr; }
- if (axis_uniform_buffer_) { wgpuBufferRelease(axis_uniform_buffer_); axis_uniform_buffer_ = nullptr; }
- if (axis_vertex_buffer_) { wgpuBufferRelease(axis_vertex_buffer_); axis_vertex_buffer_ = nullptr; }
-
// Section visualizer
// Marquee
@@ -466,288 +384,6 @@ void OverlayRenderer::destroy() {
hud_text_.clear();
}
-// -----------------------------------------------------------------------------
-// Axis indicator
-// -----------------------------------------------------------------------------
-
-bool OverlayRenderer::buildAxisIndicator() {
- // Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
- // decorator_color_error = (1.000, 0.200, 0.322) — red → +X
- // decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
- // decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
- // Same palette is reused for the section gizmo + marquee so all overlay
- // colours come from one canonical source.
- static const float axis_verts[] = {
- // start end color (RGB — Bonsai decorators) t side
- // ---- +X red ----
- 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
- 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
- 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
- 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
- 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
- 0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
- // ---- +Y green ----
- 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
- 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
- 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
- 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
- 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
- 0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
- // ---- +Z blue ----
- 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
- 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
- 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
- 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
- 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
- 0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
- };
- {
- WGPUBufferDescriptor bdesc = {};
- bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
- bdesc.size = sizeof(axis_verts);
- bdesc.label = svFromCStr("ifcviewer-wgpu.axis_vbo");
- axis_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
- wgpuQueueWriteBuffer(queue_, axis_vertex_buffer_, 0, axis_verts, sizeof(axis_verts));
- }
- {
- WGPUBufferDescriptor bdesc = {};
- bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
- bdesc.size = 3u * kAxisUniformSlotSize;
- bdesc.label = svFromCStr("ifcviewer-wgpu.axis_uniforms");
- axis_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
- }
- {
- WGPUBindGroupLayoutEntry entry = {};
- entry.binding = 0;
- entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
- entry.buffer.type = WGPUBufferBindingType_Uniform;
- entry.buffer.hasDynamicOffset = 1;
- entry.buffer.minBindingSize = 96;
- WGPUBindGroupLayoutDescriptor bgl_desc = {};
- bgl_desc.entryCount = 1;
- bgl_desc.entries = &entry;
- bgl_desc.label = svFromCStr("ifcviewer-wgpu.axis_bgl");
- axis_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
- }
- {
- WGPUPipelineLayoutDescriptor pl_desc = {};
- pl_desc.bindGroupLayoutCount = 1;
- pl_desc.bindGroupLayouts = &axis_bgl_;
- pl_desc.label = svFromCStr("ifcviewer-wgpu.axis_pipeline_layout");
- axis_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
- }
- {
- WGPUBindGroupEntry entry = {};
- entry.binding = 0;
- entry.buffer = axis_uniform_buffer_;
- entry.offset = 0;
- entry.size = kAxisUniformSlotSize;
- WGPUBindGroupDescriptor bg_desc = {};
- bg_desc.layout = axis_bgl_;
- bg_desc.entryCount = 1;
- bg_desc.entries = &entry;
- bg_desc.label = svFromCStr("ifcviewer-wgpu.axis_bind_group");
- axis_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
- }
- {
- WGPUShaderSourceWGSL wgsl_src = {};
- wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
- wgsl_src.code = svFromCStr(AXIS_WGSL.c_str());
- WGPUShaderModuleDescriptor sm_desc = {};
- sm_desc.nextInChain = &wgsl_src.chain;
- sm_desc.label = svFromCStr("ifcviewer-wgpu.axis_wgsl");
- axis_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
- }
-
- WGPUVertexAttribute attribs[5] = {};
- WGPUVertexBufferLayout vbl = thickLineVertexLayout(attribs);
-
- WGPUBlendState blend = {};
- blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
- blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
- blend.color.operation = WGPUBlendOperation_Add;
- blend.alpha.srcFactor = WGPUBlendFactor_One;
- blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
- blend.alpha.operation = WGPUBlendOperation_Add;
-
- auto build_pivot = [&](WGPUCompareFunction cmp, const char* label,
- WGPURenderPipeline& out) {
- WGPUColorTargetState ct = {};
- ct.format = surface_format_;
- ct.blend = &blend;
- ct.writeMask = WGPUColorWriteMask_All;
-
- WGPUFragmentState frag = {};
- frag.module = axis_shader_module_;
- frag.entryPoint = svFromCStr("fs_main");
- frag.targetCount = 1;
- frag.targets = &ct;
-
- WGPUDepthStencilState depth = {};
- depth.format = WGPUTextureFormat_Depth32Float;
- depth.depthWriteEnabled = WGPUOptionalBool_False;
- depth.depthCompare = cmp;
- depth.stencilFront.compare = WGPUCompareFunction_Always;
- depth.stencilBack.compare = WGPUCompareFunction_Always;
-
- WGPURenderPipelineDescriptor rp_desc = {};
- rp_desc.layout = axis_pipeline_layout_;
- rp_desc.label = svFromCStr(label);
- rp_desc.vertex.module = axis_shader_module_;
- rp_desc.vertex.entryPoint = svFromCStr("vs_main");
- rp_desc.vertex.bufferCount = 1;
- rp_desc.vertex.buffers = &vbl;
- rp_desc.fragment = &frag;
- rp_desc.depthStencil = &depth;
- rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
- rp_desc.primitive.cullMode = WGPUCullMode_None;
- rp_desc.multisample.count = uint32_t(sample_count_);
- rp_desc.multisample.mask = 0xFFFFFFFFu;
- out = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
- };
- build_pivot(WGPUCompareFunction_LessEqual,
- "ifcviewer-wgpu.axis_pivot_pipeline",
- axis_pivot_pipeline_);
- build_pivot(WGPUCompareFunction_GreaterEqual,
- "ifcviewer-wgpu.axis_pivot_xray_pipeline",
- axis_pivot_xray_pipeline_);
-
- // Corner: resolved surface, no depth, sampleCount=1.
- {
- WGPUColorTargetState ct = {};
- ct.format = surface_format_;
- ct.blend = &blend;
- ct.writeMask = WGPUColorWriteMask_All;
-
- WGPUFragmentState frag = {};
- frag.module = axis_shader_module_;
- frag.entryPoint = svFromCStr("fs_main");
- frag.targetCount = 1;
- frag.targets = &ct;
-
- WGPURenderPipelineDescriptor rp_desc = {};
- rp_desc.layout = axis_pipeline_layout_;
- rp_desc.label = svFromCStr("ifcviewer-wgpu.axis_corner_pipeline");
- rp_desc.vertex.module = axis_shader_module_;
- rp_desc.vertex.entryPoint = svFromCStr("vs_main");
- rp_desc.vertex.bufferCount = 1;
- rp_desc.vertex.buffers = &vbl;
- rp_desc.fragment = &frag;
- rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
- rp_desc.primitive.cullMode = WGPUCullMode_None;
- rp_desc.multisample.count = 1;
- rp_desc.multisample.mask = 0xFFFFFFFFu;
- axis_corner_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
- }
-
- return axis_pivot_pipeline_ && axis_pivot_xray_pipeline_
- && axis_corner_pipeline_;
-}
-
-void OverlayRenderer::encodePivot(WGPURenderPassEncoder pass,
- const OverlayFrame& f,
- bool visible) {
- if (!visible || !axis_pivot_pipeline_ || !axis_pivot_xray_pipeline_) return;
- if (f.viewport_h_px <= 0) return;
-
- // Arm length = 30 logical px projected into world at the pivot's distance.
- const float fovy_rad = qDegreesToRadians(f.camera_fov_y_deg);
- const float world_per_pixel = f.camera_distance * std::tan(fovy_rad * 0.5f)
- * 2.0f / float(f.viewport_h_px);
- const float arm_pixels = 30.0f * float(f.device_pixel_ratio);
- const float arm_world = arm_pixels * world_per_pixel;
-
- const float dpr = float(f.device_pixel_ratio);
- const float line_w = 2.5f * dpr;
- const float vw = float(f.viewport_w_px);
- const float vh = float(f.viewport_h_px);
-
- uint8_t slot_visible[256];
- uint8_t slot_xray[256];
- packAxisUniform(slot_visible, f.view_proj, f.camera_target, arm_world,
- 1.00f, line_w, vw, vh);
- packAxisUniform(slot_xray, f.view_proj, f.camera_target, arm_world,
- 0.30f, line_w, vw, vh);
- const uint32_t visible_off = 1u * kAxisUniformSlotSize;
- const uint32_t xray_off = 2u * kAxisUniformSlotSize;
- wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, visible_off,
- slot_visible, sizeof(slot_visible));
- wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, xray_off,
- slot_xray, sizeof(slot_xray));
-
- wgpuRenderPassEncoderSetVertexBuffer(pass, 0, axis_vertex_buffer_, 0,
- WGPU_WHOLE_SIZE);
- wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_xray_pipeline_);
- wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &xray_off);
- wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
- wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_pipeline_);
- wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &visible_off);
- wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
-}
-
-void OverlayRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
- WGPUTextureView surface_view,
- const OverlayFrame& f) {
- if (!axis_corner_pipeline_ || !surface_view) return;
- const int dpr = std::max(1, f.device_pixel_ratio);
- const uint32_t gizmo_size = uint32_t(110 * dpr);
- const uint32_t margin = uint32_t(10 * dpr);
- if (gizmo_size == 0 || f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
- // Bottom-left in WebGPU framebuffer space (y down).
- const uint32_t fb_h = uint32_t(f.viewport_h_px);
- if (gizmo_size + margin > fb_h) return;
- const uint32_t y = fb_h - margin - gizmo_size;
-
- // Independent ortho projection from the camera's direction. Near the
- // poles the up axis collapses against the look direction, so swap to
- // Y-up there — mirrors buildViewProj's identical fix on the viewport.
- const float yaw_rad = qDegreesToRadians(f.camera_yaw_deg);
- const float pitch_rad = qDegreesToRadians(f.camera_pitch_deg);
- const Eigen::Vector3f eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
- std::cos(pitch_rad) * std::sin(yaw_rad),
- std::sin(pitch_rad));
- const Eigen::Vector3f world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
- ? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
- : Eigen::Vector3f(0.0f, 0.0f, 1.0f);
- const Eigen::Matrix4f gv = lookAtRH(eye_dir * 3.0f, Eigen::Vector3f::Zero(), world_up);
- const Eigen::Matrix4f gp = orthoGL(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
- Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
- z_remap(2, 2) = 0.5f;
- z_remap(2, 3) = 0.5f;
- const Eigen::Matrix4f mvp = z_remap * gp * gv;
-
- uint8_t slot[256];
- const float line_w = 2.5f * float(dpr);
- packAxisUniform(slot, mvp, Eigen::Vector3f(0, 0, 0), 1.0f, 1.0f, line_w,
- float(gizmo_size), float(gizmo_size));
- const uint32_t slot_offset = 0u;
- wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, slot_offset, slot, sizeof(slot));
-
- WGPURenderPassColorAttachment color = {};
- color.view = surface_view;
- color.loadOp = WGPULoadOp_Load;
- color.storeOp = WGPUStoreOp_Store;
- color.clearValue = { 0.0, 0.0, 0.0, 1.0 };
- color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
-
- WGPURenderPassDescriptor pass_desc = {};
- pass_desc.colorAttachmentCount = 1;
- pass_desc.colorAttachments = &color;
- pass_desc.label = svFromCStr("ifcviewer-wgpu.corner_axis_pass");
-
- WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
- wgpuRenderPassEncoderSetViewport(pass, float(margin), float(y),
- float(gizmo_size), float(gizmo_size),
- 0.0f, 1.0f);
- wgpuRenderPassEncoderSetPipeline(pass, axis_corner_pipeline_);
- wgpuRenderPassEncoderSetVertexBuffer(pass, 0, axis_vertex_buffer_, 0,
- WGPU_WHOLE_SIZE);
- wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &slot_offset);
- wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
- wgpuRenderPassEncoderEnd(pass);
- wgpuRenderPassEncoderRelease(pass);
-}
-
// -----------------------------------------------------------------------------
// Marquee
// -----------------------------------------------------------------------------
diff --git a/src/ifcviewer/OverlayRenderer.h b/src/ifcviewer/OverlayRenderer.h
index eef5e60220..48dd306865 100644
--- a/src/ifcviewer/OverlayRenderer.h
+++ b/src/ifcviewer/OverlayRenderer.h
@@ -33,10 +33,14 @@
#include "OverlayFrame.h"
#include "SectionPlane.h"
-// All viewport overlays in one place: axis indicator (corner + pivot),
-// section plane gizmos, and the marquee drag rect. Mirrors GL's
-// OverlayRenderer split so ViewportWindow.cpp doesn't have to
-// carry ~1.5k lines of pipeline plumbing.
+// The Qt-coupled viewport overlays: the marquee drag rect, measure-tool
+// lines / points / highlight patches, and the QPainter-rasterised labels
+// and HUD. Mirrors GL's OverlayRenderer split so ViewportWindow.cpp
+// doesn't have to carry ~1.5k lines of pipeline plumbing.
+//
+// The Qt-free overlays live in their own shared renderers so the web build
+// gets them too: SectionGizmoRenderer and AxisIndicatorRenderer (corner
+// axis gizmo + orbit pivot), both driven by ViewportCore::render.
//
// Lifecycle: init() once after the device is up, destroy() before the
// device dies. Pipelines are immutable after init; only per-frame
@@ -56,16 +60,11 @@ public:
void destroy();
// ---- Inside the main MSAA pass, after geometry ----
- // Both share depth with the scene so they're correctly occluded.
+ // These share depth with the scene so they're correctly occluded.
- // Orbit pivot indicator. `visible` is the viewport's UI gate (orbit
- // drag / wheel-zoom afterglow). When false this is a cheap no-op.
- void encodePivot(WGPURenderPassEncoder pass,
- const OverlayFrame& f,
- bool visible);
-
- // Section-plane gizmos moved to the shared SectionGizmoRenderer (drawn by
- // ViewportCore for both desktop + web).
+ // Section-plane gizmos moved to the shared SectionGizmoRenderer, and the
+ // orbit pivot to AxisIndicatorRenderer (both drawn by ViewportCore for
+ // desktop + web).
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 vertices per triangle, in world space (post-composed-
@@ -148,12 +147,8 @@ public:
const OverlayFrame& f);
// ---- After the edge silhouette pass, on the resolved surface ----
-
- // Corner axis gizmo (bottom-left, 110×110 px). Independent ortho
- // projection — only the camera direction matters.
- void encodeCornerAxis(WGPUCommandEncoder enc,
- WGPUTextureView surface_view,
- const OverlayFrame& f);
+ // (The corner axis gizmo also draws here — from ViewportCore, via
+ // AxisIndicatorRenderer.)
// Marquee box-select drag rect (translucent fill + thick outline).
// No-op when `active` is false.
@@ -170,7 +165,6 @@ public:
static constexpr int kMaxSectionPlanes = 6;
private:
- bool buildAxisIndicator();
bool buildMarquee();
bool buildOverlayLines();
bool buildOverlayPoints();
@@ -200,19 +194,6 @@ private:
WGPUTextureFormat surface_format_ = WGPUTextureFormat_Undefined;
int sample_count_ = 1;
- // ---- Axis indicator (shared shape, three pipelines) ----
- // Slot 0 = corner gizmo. Slots 1/2 = pivot visible/x-ray.
- WGPUShaderModule axis_shader_module_ = nullptr;
- WGPUBindGroupLayout axis_bgl_ = nullptr;
- WGPUPipelineLayout axis_pipeline_layout_ = nullptr;
- WGPURenderPipeline axis_pivot_pipeline_ = nullptr;
- WGPURenderPipeline axis_pivot_xray_pipeline_ = nullptr;
- WGPURenderPipeline axis_corner_pipeline_ = nullptr;
- WGPUBuffer axis_vertex_buffer_ = nullptr;
- WGPUBuffer axis_uniform_buffer_ = nullptr;
- WGPUBindGroup axis_bind_group_ = nullptr;
- static constexpr uint32_t kAxisUniformSlotSize = 256;
-
// ---- Marquee (fill + outline pipelines, one uniform buffer) ----
WGPUShaderModule marquee_shader_module_ = nullptr;
WGPUBindGroupLayout marquee_bgl_ = nullptr;
diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp
index 2a17c590a6..b8e734391b 100644
--- a/src/ifcviewer/ViewportCore.cpp
+++ b/src/ifcviewer/ViewportCore.cpp
@@ -576,6 +576,21 @@ void ViewportCore::dollyBy(float notches) {
host_->requestFrame();
}
+void ViewportCore::setPivotIndicatorVisible(bool visible, int hide_after_ms) {
+ pivot_indicator_visible_ = visible;
+ pivot_indicator_hide_ms_ = hide_after_ms;
+ if (visible && hide_after_ms > 0) pivot_indicator_timer_.start();
+ else pivot_indicator_timer_.invalidate();
+ host_->requestFrame();
+}
+
+bool ViewportCore::pivotIndicatorVisible() const {
+ if (!pivot_indicator_visible_) return false;
+ // No armed afterglow means a drag is holding it up.
+ if (!pivot_indicator_timer_.isValid()) return true;
+ return pivot_indicator_timer_.elapsed() < pivot_indicator_hide_ms_;
+}
+
void ViewportCore::flyMove(bool fwd, bool back, bool right, bool left,
bool up, bool down, bool boost, float dt_seconds) {
if (dt_seconds <= 0.0f) return;
@@ -1297,6 +1312,10 @@ bool ViewportCore::buildPipelines() {
// Section-plane gizmo (shared desktop + web). Optional — a failure just
// means no gizmo, not a dead viewport.
section_gizmo_.init(device_, queue_, surface_view_format_, kViewportSampleCount);
+
+ // Corner axis gizmo + orbit pivot indicator (shared desktop + web).
+ // Also optional: a failure costs the indicator, not the viewport.
+ axis_indicator_.init(device_, queue_, surface_view_format_, kViewportSampleCount);
return true;
}
@@ -1913,6 +1932,7 @@ void ViewportCore::shutdown() {
if (main_pipeline_no_cull_) { wgpuRenderPipelineRelease(main_pipeline_no_cull_); main_pipeline_no_cull_ = nullptr; }
if (main_pipeline_transparent_) { wgpuRenderPipelineRelease(main_pipeline_transparent_); main_pipeline_transparent_ = nullptr; }
section_gizmo_.destroy();
+ axis_indicator_.destroy();
if (main_shader_module_) { wgpuShaderModuleRelease(main_shader_module_); main_shader_module_ = nullptr; }
if (pipeline_layout_) { wgpuPipelineLayoutRelease(pipeline_layout_); pipeline_layout_ = nullptr; }
if (model_bgl_) { wgpuBindGroupLayoutRelease(model_bgl_); model_bgl_ = nullptr; }
@@ -7606,8 +7626,15 @@ void ViewportCore::render() {
section_gizmo_.encode(pass, vp_this_frame, section_planes_,
viewport_w_px, viewport_h_px, dpr_int, section_selected_index_);
- // Remaining in-pass overlays (highlight triangles, pivot, overlay
- // lines/points). QtViewportHost forwards to overlays_.X(); web host no-ops.
+ // Orbit pivot indicator — same shared-renderer story. Drawn while the host
+ // has it gated on (drag) or an afterglow is still running; in the latter
+ // case keep frames coming so the one that clears it actually lands.
+ const bool pivot_visible = pivotIndicatorVisible();
+ axis_indicator_.encodePivot(pass, overlay_frame, pivot_visible);
+ if (pivot_visible && pivot_indicator_timer_.isValid()) host_->requestFrame();
+
+ // Remaining in-pass overlays (highlight triangles, overlay lines/points).
+ // QtViewportHost forwards to overlays_.X(); the web host no-ops.
host_->encodeOverlaysInMainPass(pass, overlay_frame);
wgpuRenderPassEncoderEnd(pass);
@@ -7626,8 +7653,12 @@ void ViewportCore::render() {
int hiz_submitted_slot = -1;
if (hiz_enabled_) hiz_submitted_slot = encodeHizResolve(enc);
- // Post-main overlays (corner axis, marquee, labels) on the resolved
- // surface. QtViewportHost forwards to overlays_.X().
+ // Corner axis gizmo on the resolved surface — shared renderer, ahead of the
+ // host's own post-main overlays so marquee / labels still stack on top.
+ axis_indicator_.encodeCornerAxis(enc, view, overlay_frame);
+
+ // Remaining post-main overlays (marquee, labels) on the resolved surface.
+ // QtViewportHost forwards to overlays_.X(); the web host no-ops.
host_->encodeOverlaysPostMain(enc, view, overlay_frame);
// Optional capture: encode copy on the same command buffer.
diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h
index a1fac57a6b..51ce2ff85b 100644
--- a/src/ifcviewer/ViewportCore.h
+++ b/src/ifcviewer/ViewportCore.h
@@ -47,6 +47,7 @@
#include
#include
+#include "AxisIndicatorRenderer.h"
#include "BufferPool.h"
#include "InstanceCompose.h"
#include "InstancedGeometry.h"
@@ -55,6 +56,7 @@
#include "SectionPlane.h"
#include "SelectionState.h"
#include "SidecarCache.h"
+#include "Stopwatch.h"
#include "StreamingLoader.h"
#include "StreamingThread.h"
#include "ViewportHost.h"
@@ -281,9 +283,9 @@ public:
//
// Pixel-delta camera moves, shared by every host (Qt desktop + web).
// Hosts translate raw pointer/wheel events into these calls and own
- // their own UI concerns (drag promotion, pivot indicator, cursor
- // capture); the orbit math lives here so it can't drift between
- // platforms. Each schedules a frame via the host.
+ // their own UI concerns (drag promotion, cursor capture); the orbit
+ // math lives here so it can't drift between platforms. Each schedules
+ // a frame via the host.
//
// orbitBy: drag-right yaws the world right (yaw -= dx), drag-down
// tilts the camera up (pitch += dy). 0.4 deg/px matches GL.
@@ -296,6 +298,18 @@ public:
void panBy(float dx_px, float dy_px, int viewport_height_px);
void dollyBy(float notches);
+ // ---- Pivot indicator ----------------------------------------------------
+ //
+ // The RGB triad drawn at the orbit target while the user navigates, so it's
+ // obvious what the camera is turning around. Hosts gate it: (true) when an
+ // orbit / pan drag starts, (false) when it ends. `hide_after_ms` > 0 arms an
+ // afterglow instead — the wheel path uses it so a zoom without a held drag
+ // still shows the pivot for a moment. State lives here (not in the host) so
+ // desktop and web behave identically; render() consults it each frame and
+ // keeps requesting frames until an armed afterglow expires.
+ void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
+ bool pivotIndicatorVisible() const;
+
// ---- First-person / fly navigation --------------------------------------
//
// Shared fly-camera math (desktop + web). The HOST owns the fly-mode flag,
@@ -1092,6 +1106,14 @@ private:
// Lifted out of the Qt-coupled OverlayRenderer so one identical gizmo draws
// everywhere; the desktop's OverlayRenderer no longer draws it.
SectionGizmoRenderer section_gizmo_;
+ // Corner axis gizmo + orbit pivot indicator, likewise shared by desktop +
+ // web. Same lift out of the Qt-coupled OverlayRenderer.
+ AxisIndicatorRenderer axis_indicator_;
+ bool pivot_indicator_visible_ = false;
+ // Only running while an afterglow is armed; a drag-held indicator leaves it
+ // invalid so the triad stays up until the host clears it.
+ Stopwatch pivot_indicator_timer_;
+ int pivot_indicator_hide_ms_ = 0;
// HiZ occlusion-cull pipeline group. Downsamples MSAA depth into a
// mip pyramid; consumed by next-frame cull.
diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp
index 3dbba6a350..3caf7b49eb 100644
--- a/src/ifcviewer/ViewportWindow.cpp
+++ b/src/ifcviewer/ViewportWindow.cpp
@@ -435,14 +435,14 @@ void ViewportWindow::onFrameStats(const FrameStats& stats) {
void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
const OverlayFrame& frame) {
- // Section gizmos, highlight triangles, pivot, overlay lines / points
- // — drawn inside the MSAA pass so depth-test correctly hides them
- // behind closer geometry. (Corner axis / marquee / labels run on the
- // resolved surface; see encodeOverlaysPostMain.)
- // NB: section-plane gizmos now draw from ViewportCore::render via the shared
- // SectionGizmoRenderer (desktop + web), so they are NOT drawn here.
+ // Highlight triangles + overlay lines / points — drawn inside the MSAA
+ // pass so depth-test correctly hides them behind closer geometry.
+ // (Marquee / labels run on the resolved surface; see
+ // encodeOverlaysPostMain.)
+ // NB: section-plane gizmos and the pivot indicator now draw from
+ // ViewportCore::render via their shared renderers (desktop + web), so
+ // they are NOT drawn here.
overlays_.encodeHighlightTriangles(pass, frame);
- overlays_.encodePivot(pass, frame, pivot_indicator_visible_);
overlays_.encodeOverlayLines(pass, frame);
overlays_.encodeOverlayPoints(pass, frame);
}
@@ -450,7 +450,8 @@ void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
void ViewportWindow::encodeOverlaysPostMain(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& frame) {
- overlays_.encodeCornerAxis(enc, surface_view, frame);
+ // NB: the corner axis gizmo draws from ViewportCore::render (shared
+ // AxisIndicatorRenderer), just before this hook.
overlays_.encodeMarquee(enc, surface_view, frame,
box_select_start_pos_,
box_select_current_pos_,
@@ -907,25 +908,8 @@ bool ViewportWindow::initWgpu() {
// encodeEdgePass moved to ViewportCore (#84-s).
-// -----------------------------------------------------------------------------
-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);
- QObject::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();
- }
- requestUpdate();
-}
+// setPivotIndicatorVisible moved to ViewportCore (drawn by the shared
+// AxisIndicatorRenderer, so the visibility gate lives there too).
// releaseEdgeResources moved to ViewportCore (#84-s).
// -----------------------------------------------------------------------------
@@ -1585,11 +1569,11 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
if (event->button() == orbit_button_
&& (mods & Qt::KeyboardModifierMask) == orbit_mods_) {
nav_drag_kind_ = NavDrag::Orbit;
- setPivotIndicatorVisible(true); // hidden again on release
+ core_.setPivotIndicatorVisible(true); // hidden again on release
} else if (event->button() == pan_button_
&& (mods & Qt::KeyboardModifierMask) == pan_mods_) {
nav_drag_kind_ = NavDrag::Pan;
- setPivotIndicatorVisible(true);
+ core_.setPivotIndicatorVisible(true);
} else if (event->button() == select_button_
&& !section_tool_active_
&& tool_mode_ != ToolMode::Area
@@ -1683,7 +1667,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
}
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
- setPivotIndicatorVisible(false);
+ core_.setPivotIndicatorVisible(false);
return;
}
@@ -1700,7 +1684,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
- setPivotIndicatorVisible(false);
+ core_.setPivotIndicatorVisible(false);
return;
}
@@ -1715,7 +1699,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
- setPivotIndicatorVisible(false);
+ core_.setPivotIndicatorVisible(false);
return;
}
@@ -1800,7 +1784,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
// Drag is over — hide the pivot indicator without afterglow.
- setPivotIndicatorVisible(false);
+ core_.setPivotIndicatorVisible(false);
}
}
@@ -2050,7 +2034,7 @@ void ViewportWindow::wheelEvent(QWheelEvent* event) {
core_.dollyBy(notches);
// Pivot afterglow on wheel — visible for 600 ms so the user can see
// what they're zooming around without holding a drag.
- setPivotIndicatorVisible(true, 600);
+ core_.setPivotIndicatorVisible(true, 600);
}
void ViewportWindow::shutdown() {
diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h
index 9038fb1f83..18a019db7b 100644
--- a/src/ifcviewer/ViewportWindow.h
+++ b/src/ifcviewer/ViewportWindow.h
@@ -21,7 +21,6 @@
#define WGPUVIEWPORTWINDOW_H
#include
-#include
#include
#include
@@ -306,12 +305,9 @@ private:
bool buildHizPipeline();
bool buildEdgePipeline();
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
- // Show/hide the pivot indicator. hide_after_ms > 0 starts the
- // single-shot auto-hide timer used by the wheel-zoom afterglow;
- // drag callers pass 0 and toggle manually on press/release. The
- // actual gizmo rendering lives in OverlayRenderer — this just
- // manages the UI-side visibility timer.
- void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
+ // setPivotIndicatorVisible moved to ViewportCore — the indicator is drawn
+ // by the shared AxisIndicatorRenderer now, so its visibility (afterglow
+ // included) lives next to the drawing for desktop + web alike.
// releaseEdgeResources / buildPickPipeline / ensurePickAttachments /
// releasePickResources moved to ViewportCore (#84-s, #84-t).
@@ -670,15 +666,10 @@ private:
WGPUBindGroup& edge_bind_group_;
bool& edges_enabled_;
- // Pivot visibility state — the gizmo itself lives in overlays_.
- // The timer auto-hides the pivot after a wheel-zoom afterglow.
- bool pivot_indicator_visible_ = false;
- QTimer* pivot_indicator_hide_timer_ = nullptr;
-
- // All viewport overlays (axis indicator, section gizmos, marquee
- // rect) — pipelines + shaders + buffers + encoders. The viewport
- // builds a OverlayFrame each frame and asks the renderer to
- // encode each overlay; see OverlayRenderer.h.
+ // The Qt-coupled viewport overlays (marquee rect, measure lines /
+ // points / labels, highlight triangles) — pipelines + shaders +
+ // buffers + encoders. The viewport builds a OverlayFrame each frame
+ // and asks the renderer to encode each overlay; see OverlayRenderer.h.
OverlayRenderer overlays_;
// Active measurement tool. setToolMode() / setSelection mutations