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