diff --git a/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp
new file mode 100644
index 0000000000..2ba209a56b
--- /dev/null
+++ b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp
@@ -0,0 +1,1027 @@
+/********************************************************************************
+ * *
+ * 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 "WgpuOverlayRenderer.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+// -----------------------------------------------------------------------------
+// Local helpers
+// -----------------------------------------------------------------------------
+
+namespace {
+
+WGPUStringView svFromCStr(const char* s) {
+ WGPUStringView v{};
+ v.data = s;
+ v.length = std::strlen(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 QMatrix4x4& mvp, const QVector3D& origin,
+ float arm, float alpha, float line_width_px,
+ float viewport_w, float viewport_h) {
+ std::memset(dst, 0, 256);
+ std::memcpy(dst, mvp.constData(), 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));
+}
+
+// Pack the section uniform's 256-byte slot. Layout matches WGSL
+// SectionUniforms: mat4 + 4×(vec3 + scalar pad) + vec4 + vec2 + 8 B pad
+// = 160 B used, padded to 256.
+void packSectionUniform(uint8_t* dst,
+ const QMatrix4x4& mvp,
+ const QVector3D& origin, float half_size,
+ const QVector3D& tangent, float line_width_px,
+ const QVector3D& bitangent,
+ const QVector3D& normal,
+ float r, float g, float b, float a,
+ float viewport_w, float viewport_h) {
+ std::memset(dst, 0, 256);
+ std::memcpy(dst, mvp.constData(), 16 * sizeof(float));
+ auto put_vec3_pad = [&](size_t off, const QVector3D& v, float pad_val) {
+ float vx = v.x(), vy = v.y(), vz = v.z();
+ std::memcpy(dst + off + 0, &vx, sizeof(float));
+ std::memcpy(dst + off + 4, &vy, sizeof(float));
+ std::memcpy(dst + off + 8, &vz, sizeof(float));
+ std::memcpy(dst + off + 12, &pad_val, sizeof(float));
+ };
+ put_vec3_pad(64, origin, half_size);
+ put_vec3_pad(80, tangent, line_width_px);
+ put_vec3_pad(96, bitangent, 0.0f);
+ put_vec3_pad(112, normal, 0.0f);
+ float tint[4] = { r, g, b, a };
+ std::memcpy(dst + 128, tint, sizeof(tint));
+ std::memcpy(dst + 144, &viewport_w, sizeof(float));
+ std::memcpy(dst + 148, &viewport_h, sizeof(float));
+}
+
+} // namespace
+
+// -----------------------------------------------------------------------------
+// Shared WGSL — VsOut + thick_line_clip helper + fs_main AA fragment
+// -----------------------------------------------------------------------------
+
+#define THICK_LINE_HELPERS_WGSL 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);
+}
+)WGSL"
+
+static const char* AXIS_WGSL = 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 char* SECTION_WGSL = THICK_LINE_HELPERS_WGSL R"WGSL(
+struct SectionUniforms {
+ mvp: mat4x4,
+ origin: vec3,
+ half_size: f32,
+ tangent: vec3,
+ line_width_px: f32,
+ bitangent: vec3,
+ _pad1: f32,
+ normal: vec3,
+ _pad2: f32,
+ tint: vec4,
+ viewport_size: vec2,
+ _pad3: vec2,
+};
+
+@group(0) @binding(0) var u: SectionUniforms;
+
+fn plane_to_world(p: vec3) -> vec3 {
+ return u.origin + (u.tangent * p.x + u.bitangent * p.y + u.normal * p.z)
+ * u.half_size;
+}
+
+@vertex
+fn vs_main(@location(0) start_local: vec3,
+ @location(1) end_local: vec3,
+ @location(2) col: vec3,
+ @location(3) t: f32,
+ @location(4) side: f32) -> VsOut {
+ let p_start = u.mvp * vec4(plane_to_world(start_local), 1.0);
+ let p_end = u.mvp * vec4(plane_to_world(end_local), 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.tint.xyz, u.tint.w);
+ out.side_t = side;
+ return out;
+}
+)WGSL";
+
+static const char* MARQUEE_WGSL = THICK_LINE_HELPERS_WGSL R"WGSL(
+struct MarqueeUniforms {
+ rect_min: vec2,
+ rect_max: vec2,
+ color: vec4,
+ viewport_size: vec2,
+ line_width_px: f32,
+ fill_alpha: f32,
+};
+
+@group(0) @binding(0) var u: MarqueeUniforms;
+
+@vertex
+fn vs_main(@location(0) start_uv: vec2,
+ @location(1) end_uv: vec2,
+ @location(2) t: f32,
+ @location(3) side: f32) -> VsOut {
+ let p_start = vec4(mix(u.rect_min, u.rect_max, start_uv), 0.0, 1.0);
+ let p_end = vec4(mix(u.rect_min, u.rect_max, end_uv), 0.0, 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 = u.color;
+ out.side_t = side;
+ return out;
+}
+
+struct VsFillOut {
+ @builtin(position) clip_pos: vec4,
+};
+
+@vertex
+fn vs_fill(@location(0) pos_uv: vec2) -> VsFillOut {
+ var out: VsFillOut;
+ let p = mix(u.rect_min, u.rect_max, pos_uv);
+ out.clip_pos = vec4(p, 0.0, 1.0);
+ return out;
+}
+
+@fragment
+fn fs_fill() -> @location(0) vec4 {
+ return vec4(u.color.xyz, u.color.w * u.fill_alpha);
+}
+)WGSL";
+
+// -----------------------------------------------------------------------------
+// Construction / destruction
+// -----------------------------------------------------------------------------
+
+WgpuOverlayRenderer::~WgpuOverlayRenderer() {
+ destroy();
+}
+
+bool WgpuOverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
+ WGPUQueue queue, WGPUTextureFormat surface_format,
+ int sample_count) {
+ instance_ = instance;
+ device_ = device;
+ queue_ = queue;
+ surface_format_ = surface_format;
+ sample_count_ = sample_count;
+ if (!buildAxisIndicator()) return false;
+ if (!buildSectionVisualizer()) return false;
+ if (!buildMarquee()) return false;
+ return true;
+}
+
+void WgpuOverlayRenderer::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
+ if (section_bind_group_) { wgpuBindGroupRelease(section_bind_group_); section_bind_group_ = nullptr; }
+ if (section_pipeline_) { wgpuRenderPipelineRelease(section_pipeline_); section_pipeline_ = nullptr; }
+ if (section_shader_module_) { wgpuShaderModuleRelease(section_shader_module_); section_shader_module_ = nullptr; }
+ if (section_pipeline_layout_) { wgpuPipelineLayoutRelease(section_pipeline_layout_); section_pipeline_layout_ = nullptr; }
+ if (section_bgl_) { wgpuBindGroupLayoutRelease(section_bgl_); section_bgl_ = nullptr; }
+ if (section_uniform_buffer_) { wgpuBufferRelease(section_uniform_buffer_); section_uniform_buffer_ = nullptr; }
+ if (section_vertex_buffer_) { wgpuBufferRelease(section_vertex_buffer_); section_vertex_buffer_ = nullptr; }
+
+ // Marquee
+ if (marquee_bind_group_) { wgpuBindGroupRelease(marquee_bind_group_); marquee_bind_group_ = nullptr; }
+ if (marquee_pipeline_) { wgpuRenderPipelineRelease(marquee_pipeline_); marquee_pipeline_ = nullptr; }
+ if (marquee_fill_pipeline_) { wgpuRenderPipelineRelease(marquee_fill_pipeline_); marquee_fill_pipeline_ = nullptr; }
+ if (marquee_shader_module_) { wgpuShaderModuleRelease(marquee_shader_module_); marquee_shader_module_ = nullptr; }
+ if (marquee_pipeline_layout_) { wgpuPipelineLayoutRelease(marquee_pipeline_layout_); marquee_pipeline_layout_ = nullptr; }
+ if (marquee_bgl_) { wgpuBindGroupLayoutRelease(marquee_bgl_); marquee_bgl_ = nullptr; }
+ if (marquee_uniform_buffer_) { wgpuBufferRelease(marquee_uniform_buffer_); marquee_uniform_buffer_ = nullptr; }
+ if (marquee_vertex_buffer_) { wgpuBufferRelease(marquee_vertex_buffer_); marquee_vertex_buffer_ = nullptr; }
+ if (marquee_fill_vertex_buffer_) { wgpuBufferRelease(marquee_fill_vertex_buffer_); marquee_fill_vertex_buffer_ = nullptr; }
+}
+
+// -----------------------------------------------------------------------------
+// Axis indicator
+// -----------------------------------------------------------------------------
+
+bool WgpuOverlayRenderer::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);
+ 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 WgpuOverlayRenderer::encodePivot(WGPURenderPassEncoder pass,
+ const WgpuOverlayFrame& 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 WgpuOverlayRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
+ WGPUTextureView surface_view,
+ const WgpuOverlayFrame& 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 QVector3D eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
+ std::cos(pitch_rad) * std::sin(yaw_rad),
+ std::sin(pitch_rad));
+ const QVector3D world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
+ ? QVector3D(0.0f, 1.0f, 0.0f)
+ : QVector3D(0.0f, 0.0f, 1.0f);
+ QMatrix4x4 gv;
+ gv.lookAt(eye_dir * 3.0f, QVector3D(0, 0, 0), world_up);
+ QMatrix4x4 gp;
+ gp.ortho(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
+ QMatrix4x4 z_remap;
+ z_remap(2, 2) = 0.5f;
+ z_remap(2, 3) = 0.5f;
+ const QMatrix4x4 mvp = z_remap * gp * gv;
+
+ uint8_t slot[256];
+ const float line_w = 2.5f * float(dpr);
+ packAxisUniform(slot, mvp, QVector3D(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);
+}
+
+// -----------------------------------------------------------------------------
+// Section plane visualizer
+// -----------------------------------------------------------------------------
+
+bool WgpuOverlayRenderer::buildSectionVisualizer() {
+ struct Seg {
+ std::array s, e;
+ std::array c;
+ };
+ static constexpr std::array kSectionRed = {1.000f, 0.200f, 0.322f};
+ static const Seg segs[] = {
+ // ---- quad outline ----
+ { {-1, -1, 0}, { 1, -1, 0}, kSectionRed },
+ { { 1, -1, 0}, { 1, 1, 0}, kSectionRed },
+ { { 1, 1, 0}, {-1, 1, 0}, kSectionRed },
+ { {-1, 1, 0}, {-1, -1, 0}, kSectionRed },
+ // ---- arrow shaft along +n ----
+ { { 0, 0, 0}, { 0, 0, 1}, kSectionRed },
+ // ---- arrow head: 4 diagonals from tip to ring at z = 0.78 ----
+ { { 0, 0, 1}, {-0.18f, 0, 0.78f}, kSectionRed },
+ { { 0, 0, 1}, { 0.18f, 0, 0.78f}, kSectionRed },
+ { { 0, 0, 1}, { 0, -0.18f, 0.78f}, kSectionRed },
+ { { 0, 0, 1}, { 0, 0.18f, 0.78f}, kSectionRed },
+ };
+ std::vector verts;
+ verts.reserve(std::size(segs) * 6 * 11);
+ auto push_v = [&](const Seg& s, float t, float side) {
+ verts.insert(verts.end(), { s.s[0], s.s[1], s.s[2],
+ s.e[0], s.e[1], s.e[2],
+ s.c[0], s.c[1], s.c[2],
+ t, side });
+ };
+ for (const auto& s : segs) {
+ push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
+ push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
+ }
+ {
+ WGPUBufferDescriptor bdesc = {};
+ bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
+ bdesc.size = verts.size() * sizeof(float);
+ bdesc.label = svFromCStr("ifcviewer-wgpu.section_gizmo_vbo");
+ section_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
+ wgpuQueueWriteBuffer(queue_, section_vertex_buffer_, 0,
+ verts.data(), verts.size() * sizeof(float));
+ }
+ {
+ WGPUBufferDescriptor bdesc = {};
+ bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
+ bdesc.size = uint64_t(kMaxSectionPlanes) * kSectionUniformSlotSize;
+ bdesc.label = svFromCStr("ifcviewer-wgpu.section_uniforms");
+ section_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 = 160;
+ WGPUBindGroupLayoutDescriptor bgl_desc = {};
+ bgl_desc.entryCount = 1;
+ bgl_desc.entries = &entry;
+ bgl_desc.label = svFromCStr("ifcviewer-wgpu.section_bgl");
+ section_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
+ }
+ {
+ WGPUPipelineLayoutDescriptor pl_desc = {};
+ pl_desc.bindGroupLayoutCount = 1;
+ pl_desc.bindGroupLayouts = §ion_bgl_;
+ pl_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline_layout");
+ section_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
+ }
+ {
+ WGPUBindGroupEntry entry = {};
+ entry.binding = 0;
+ entry.buffer = section_uniform_buffer_;
+ entry.offset = 0;
+ entry.size = kSectionUniformSlotSize;
+ WGPUBindGroupDescriptor bg_desc = {};
+ bg_desc.layout = section_bgl_;
+ bg_desc.entryCount = 1;
+ bg_desc.entries = &entry;
+ bg_desc.label = svFromCStr("ifcviewer-wgpu.section_bind_group");
+ section_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
+ }
+ {
+ WGPUShaderSourceWGSL wgsl_src = {};
+ wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
+ wgsl_src.code = svFromCStr(SECTION_WGSL);
+ WGPUShaderModuleDescriptor sm_desc = {};
+ sm_desc.nextInChain = &wgsl_src.chain;
+ sm_desc.label = svFromCStr("ifcviewer-wgpu.section_wgsl");
+ section_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;
+
+ WGPUColorTargetState ct = {};
+ ct.format = surface_format_;
+ ct.blend = &blend;
+ ct.writeMask = WGPUColorWriteMask_All;
+
+ WGPUFragmentState frag = {};
+ frag.module = section_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 = WGPUCompareFunction_LessEqual;
+ depth.stencilFront.compare = WGPUCompareFunction_Always;
+ depth.stencilBack.compare = WGPUCompareFunction_Always;
+
+ WGPURenderPipelineDescriptor rp_desc = {};
+ rp_desc.layout = section_pipeline_layout_;
+ rp_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline");
+ rp_desc.vertex.module = section_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;
+ section_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
+
+ return section_pipeline_ != nullptr;
+}
+
+void WgpuOverlayRenderer::encodeSectionGizmos(WGPURenderPassEncoder pass,
+ const WgpuOverlayFrame& f,
+ const std::vector& planes) {
+ if (!section_pipeline_ || planes.empty()) return;
+
+ wgpuRenderPassEncoderSetPipeline(pass, section_pipeline_);
+ wgpuRenderPassEncoderSetVertexBuffer(pass, 0, section_vertex_buffer_, 0,
+ WGPU_WHOLE_SIZE);
+
+ const int n = std::min(int(planes.size()), kMaxSectionPlanes);
+ for (int i = 0; i < n; ++i) {
+ const WgpuSectionPlane& p = planes[i];
+
+ // Stable in-plane basis: pick the world axis least parallel to n
+ // so the cross-product stays well-conditioned at any orientation.
+ QVector3D nn = p.n.normalized();
+ const float ax = std::abs(nn.x()), ay = std::abs(nn.y()), az = std::abs(nn.z());
+ QVector3D seed = (ax < ay && ax < az) ? QVector3D(1, 0, 0)
+ : (ay < az) ? QVector3D(0, 1, 0)
+ : QVector3D(0, 0, 1);
+ QVector3D tangent = QVector3D::crossProduct(nn, seed);
+ if (tangent.lengthSquared() < 1e-12f) tangent = QVector3D(1, 0, 0);
+ tangent.normalize();
+ QVector3D bitangent = QVector3D::crossProduct(nn, tangent).normalized();
+
+ // Fixed 1 m half-size matches GL's renderSectionPlanes constant.
+ const float half_size = 1.0f;
+ const float dpr = float(std::max(1, f.device_pixel_ratio));
+ const float line_w = 5.0f * dpr;
+ const float vw = float(f.viewport_w_px);
+ const float vh = float(f.viewport_h_px);
+
+ uint8_t slot[256];
+ // Neutral tint — actual colours come from the per-vertex VBO
+ // (red quad outline + red arrow). Tint stays available for a
+ // future "selected" multiplier.
+ packSectionUniform(slot, f.view_proj, p.origin, half_size,
+ tangent, line_w, bitangent, nn,
+ 1.0f, 1.0f, 1.0f, 1.0f,
+ vw, vh);
+ const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlotSize;
+ wgpuQueueWriteBuffer(queue_, section_uniform_buffer_,
+ slot_offset, slot, sizeof(slot));
+
+ wgpuRenderPassEncoderSetBindGroup(pass, 0, section_bind_group_,
+ 1, &slot_offset);
+ wgpuRenderPassEncoderDraw(pass, 54, 1, 0, 0);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Marquee
+// -----------------------------------------------------------------------------
+
+bool WgpuOverlayRenderer::buildMarquee() {
+ struct Seg { std::array s, e; };
+ static const Seg segs[] = {
+ { {0, 0}, {1, 0} },
+ { {1, 0}, {1, 1} },
+ { {1, 1}, {0, 1} },
+ { {0, 1}, {0, 0} },
+ };
+ std::vector verts;
+ verts.reserve(std::size(segs) * 6 * 6);
+ auto push_v = [&](const Seg& s, float t, float side) {
+ verts.insert(verts.end(), { s.s[0], s.s[1], s.e[0], s.e[1], t, side });
+ };
+ for (const auto& s : segs) {
+ push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
+ push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
+ }
+ {
+ WGPUBufferDescriptor bdesc = {};
+ bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
+ bdesc.size = verts.size() * sizeof(float);
+ bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_vbo");
+ marquee_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
+ wgpuQueueWriteBuffer(queue_, marquee_vertex_buffer_, 0,
+ verts.data(), verts.size() * sizeof(float));
+ }
+ static const float fill_verts[] = {
+ 0, 0, 1, 0, 1, 1,
+ 0, 0, 1, 1, 0, 1,
+ };
+ {
+ WGPUBufferDescriptor bdesc = {};
+ bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
+ bdesc.size = sizeof(fill_verts);
+ bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_fill_vbo");
+ marquee_fill_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
+ wgpuQueueWriteBuffer(queue_, marquee_fill_vertex_buffer_, 0,
+ fill_verts, sizeof(fill_verts));
+ }
+ {
+ WGPUBufferDescriptor bdesc = {};
+ bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
+ bdesc.size = 64;
+ bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_uniforms");
+ marquee_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
+ }
+ {
+ WGPUBindGroupLayoutEntry entry = {};
+ entry.binding = 0;
+ entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
+ entry.buffer.type = WGPUBufferBindingType_Uniform;
+ entry.buffer.hasDynamicOffset = 0;
+ entry.buffer.minBindingSize = 48;
+ WGPUBindGroupLayoutDescriptor bgl_desc = {};
+ bgl_desc.entryCount = 1;
+ bgl_desc.entries = &entry;
+ bgl_desc.label = svFromCStr("ifcviewer-wgpu.marquee_bgl");
+ marquee_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
+ }
+ {
+ WGPUPipelineLayoutDescriptor pl_desc = {};
+ pl_desc.bindGroupLayoutCount = 1;
+ pl_desc.bindGroupLayouts = &marquee_bgl_;
+ pl_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pipeline_layout");
+ marquee_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
+ }
+ {
+ WGPUBindGroupEntry entry = {};
+ entry.binding = 0;
+ entry.buffer = marquee_uniform_buffer_;
+ entry.offset = 0;
+ entry.size = 64;
+ WGPUBindGroupDescriptor bg_desc = {};
+ bg_desc.layout = marquee_bgl_;
+ bg_desc.entryCount = 1;
+ bg_desc.entries = &entry;
+ bg_desc.label = svFromCStr("ifcviewer-wgpu.marquee_bind_group");
+ marquee_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
+ }
+ {
+ WGPUShaderSourceWGSL wgsl_src = {};
+ wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
+ wgsl_src.code = svFromCStr(MARQUEE_WGSL);
+ WGPUShaderModuleDescriptor sm_desc = {};
+ sm_desc.nextInChain = &wgsl_src.chain;
+ sm_desc.label = svFromCStr("ifcviewer-wgpu.marquee_wgsl");
+ marquee_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
+ }
+
+ // Vertex layout: start_uv(vec2) + end_uv(vec2) + t(f32) + side(f32),
+ // stride 24.
+ WGPUVertexAttribute attribs[4] = {};
+ attribs[0].format = WGPUVertexFormat_Float32x2; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
+ attribs[1].format = WGPUVertexFormat_Float32x2; attribs[1].offset = 8; attribs[1].shaderLocation = 1;
+ attribs[2].format = WGPUVertexFormat_Float32; attribs[2].offset = 16; attribs[2].shaderLocation = 2;
+ attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 20; attribs[3].shaderLocation = 3;
+ WGPUVertexBufferLayout vbl = {};
+ vbl.arrayStride = 24;
+ vbl.stepMode = WGPUVertexStepMode_Vertex;
+ vbl.attributeCount = 4;
+ 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;
+
+ WGPUColorTargetState ct = {};
+ ct.format = surface_format_;
+ ct.blend = &blend;
+ ct.writeMask = WGPUColorWriteMask_All;
+
+ WGPUFragmentState frag = {};
+ frag.module = marquee_shader_module_;
+ frag.entryPoint = svFromCStr("fs_main");
+ frag.targetCount = 1;
+ frag.targets = &ct;
+
+ WGPURenderPipelineDescriptor rp_desc = {};
+ rp_desc.layout = marquee_pipeline_layout_;
+ rp_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pipeline");
+ rp_desc.vertex.module = marquee_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;
+ marquee_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
+
+ WGPUVertexAttribute fill_attribs[1] = {};
+ fill_attribs[0].format = WGPUVertexFormat_Float32x2;
+ fill_attribs[0].offset = 0;
+ fill_attribs[0].shaderLocation = 0;
+ WGPUVertexBufferLayout fill_vbl = {};
+ fill_vbl.arrayStride = 8;
+ fill_vbl.stepMode = WGPUVertexStepMode_Vertex;
+ fill_vbl.attributeCount = 1;
+ fill_vbl.attributes = fill_attribs;
+
+ WGPUFragmentState fill_frag = {};
+ fill_frag.module = marquee_shader_module_;
+ fill_frag.entryPoint = svFromCStr("fs_fill");
+ fill_frag.targetCount = 1;
+ fill_frag.targets = &ct;
+
+ WGPURenderPipelineDescriptor fill_rp_desc = rp_desc;
+ fill_rp_desc.label = svFromCStr("ifcviewer-wgpu.marquee_fill_pipeline");
+ fill_rp_desc.vertex.entryPoint = svFromCStr("vs_fill");
+ fill_rp_desc.vertex.bufferCount = 1;
+ fill_rp_desc.vertex.buffers = &fill_vbl;
+ fill_rp_desc.fragment = &fill_frag;
+ marquee_fill_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &fill_rp_desc);
+
+ return marquee_pipeline_ != nullptr && marquee_fill_pipeline_ != nullptr;
+}
+
+void WgpuOverlayRenderer::encodeMarquee(WGPUCommandEncoder enc,
+ WGPUTextureView surface_view,
+ const WgpuOverlayFrame& f,
+ QPoint start_logical_px,
+ QPoint current_logical_px,
+ bool active) {
+ if (!marquee_pipeline_ || !surface_view) return;
+ if (!active) return;
+ if (f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
+
+ const float w = float(f.viewport_w_px);
+ const float h = float(f.viewport_h_px);
+ const float dpr = float(std::max(1, f.device_pixel_ratio));
+ const float lx0 = float(std::min(start_logical_px.x(),
+ current_logical_px.x())) * dpr;
+ const float ly0 = float(std::min(start_logical_px.y(),
+ current_logical_px.y())) * dpr;
+ const float lx1 = float(std::max(start_logical_px.x(),
+ current_logical_px.x())) * dpr;
+ const float ly1 = float(std::max(start_logical_px.y(),
+ current_logical_px.y())) * dpr;
+ if (lx1 <= lx0 || ly1 <= ly0) return;
+
+ const float nx0 = (lx0 / w) * 2.0f - 1.0f;
+ const float nx1 = (lx1 / w) * 2.0f - 1.0f;
+ const float ny_top = 1.0f - 2.0f * ly0 / h;
+ const float ny_bottom = 1.0f - 2.0f * ly1 / h;
+
+ // Bonsai decorator_color_special (axis +Z blue): 0.157, 0.565, 1.000.
+ // Outline alpha 0.95; fill_alpha (multiplied onto that) gives ~0.19
+ // alpha for the translucent fill.
+ float uniforms[16] = {};
+ uniforms[0] = nx0; uniforms[1] = ny_top;
+ uniforms[2] = nx1; uniforms[3] = ny_bottom;
+ uniforms[4] = 0.157f; uniforms[5] = 0.565f;
+ uniforms[6] = 1.000f; uniforms[7] = 0.95f;
+ uniforms[8] = w; uniforms[9] = h;
+ uniforms[10] = 3.0f * dpr;
+ uniforms[11] = 0.20f;
+ wgpuQueueWriteBuffer(queue_, marquee_uniform_buffer_, 0,
+ uniforms, 12 * sizeof(float));
+
+ WGPURenderPassColorAttachment color = {};
+ color.view = surface_view;
+ color.loadOp = WGPULoadOp_Load;
+ color.storeOp = WGPUStoreOp_Store;
+ color.clearValue = { 0, 0, 0, 1 };
+ color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
+
+ WGPURenderPassDescriptor pass_desc = {};
+ pass_desc.colorAttachmentCount = 1;
+ pass_desc.colorAttachments = &color;
+ pass_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pass");
+
+ WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
+ wgpuRenderPassEncoderSetBindGroup(pass, 0, marquee_bind_group_, 0, nullptr);
+
+ wgpuRenderPassEncoderSetPipeline(pass, marquee_fill_pipeline_);
+ wgpuRenderPassEncoderSetVertexBuffer(pass, 0, marquee_fill_vertex_buffer_,
+ 0, WGPU_WHOLE_SIZE);
+ wgpuRenderPassEncoderDraw(pass, 6, 1, 0, 0);
+
+ wgpuRenderPassEncoderSetPipeline(pass, marquee_pipeline_);
+ wgpuRenderPassEncoderSetVertexBuffer(pass, 0, marquee_vertex_buffer_, 0,
+ WGPU_WHOLE_SIZE);
+ wgpuRenderPassEncoderDraw(pass, 24, 1, 0, 0);
+
+ wgpuRenderPassEncoderEnd(pass);
+ wgpuRenderPassEncoderRelease(pass);
+}
diff --git a/src/ifcviewer-wgpu/WgpuOverlayRenderer.h b/src/ifcviewer-wgpu/WgpuOverlayRenderer.h
new file mode 100644
index 0000000000..9412791328
--- /dev/null
+++ b/src/ifcviewer-wgpu/WgpuOverlayRenderer.h
@@ -0,0 +1,166 @@
+/********************************************************************************
+ * *
+ * 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 WGPUOVERLAYRENDERER_H
+#define WGPUOVERLAYRENDERER_H
+
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+
+// Per-frame snapshot of viewport state that every overlay needs. Built once
+// at the top of render() and passed by const-ref to each encodeX() call so
+// the overlay renderer never reaches back into the viewport.
+struct WgpuOverlayFrame {
+ QMatrix4x4 view_proj;
+ QVector3D camera_target;
+ float camera_distance = 5.0f;
+ float camera_yaw_deg = 0.0f;
+ float camera_pitch_deg = 0.0f;
+ float camera_fov_y_deg = 45.0f;
+ int viewport_w_px = 0;
+ int viewport_h_px = 0;
+ int device_pixel_ratio = 1;
+};
+
+// One section plane as the visualizer consumes it. The viewport owns the
+// authoritative state vector (the section tool mutates it); the overlay
+// reads from a non-owning span every frame. Held by value because the
+// struct is small and copies happen at most six times per frame.
+struct WgpuSectionPlane {
+ QVector3D n; // unit normal (camera-facing after auto-flip)
+ float d; // -dot(n, origin)
+ QVector3D origin; // surface point at the moment the plane was added
+ float visual_radius; // unused by the visualizer (kept here so the
+ // section tool's state struct round-trips
+ // through this overlay-facing definition).
+};
+
+// All viewport overlays in one place: axis indicator (corner + pivot),
+// section plane gizmos, and the marquee drag rect. Mirrors GL's
+// OverlayRenderer split so WgpuViewportWindow.cpp doesn't have to
+// carry ~1.5k lines of pipeline plumbing.
+//
+// Lifecycle: init() once after the device is up, destroy() before the
+// device dies. Pipelines are immutable after init; only per-frame
+// uniforms get re-written.
+//
+// Threading: all calls are main-thread only — they touch the wgpu queue.
+class WgpuOverlayRenderer {
+public:
+ WgpuOverlayRenderer() = default;
+ ~WgpuOverlayRenderer();
+
+ WgpuOverlayRenderer(const WgpuOverlayRenderer&) = delete;
+ WgpuOverlayRenderer& operator=(const WgpuOverlayRenderer&) = delete;
+
+ bool init(WGPUInstance instance, WGPUDevice device, WGPUQueue queue,
+ WGPUTextureFormat surface_format, int sample_count);
+ void destroy();
+
+ // ---- Inside the main MSAA pass, after geometry ----
+ // Both 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 WgpuOverlayFrame& f,
+ bool visible);
+
+ // Per-plane wireframe gizmo (2 × 2 m quad outline + arrow shaft +
+ // arrow head). Drawn at each plane's origin in its local basis;
+ // colour comes from Bonsai's decorator_color_error.
+ void encodeSectionGizmos(WGPURenderPassEncoder pass,
+ const WgpuOverlayFrame& f,
+ const std::vector& planes);
+
+ // ---- 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 WgpuOverlayFrame& f);
+
+ // Marquee box-select drag rect (translucent fill + thick outline).
+ // No-op when `active` is false.
+ void encodeMarquee(WGPUCommandEncoder enc,
+ WGPUTextureView surface_view,
+ const WgpuOverlayFrame& f,
+ QPoint start_logical_px,
+ QPoint current_logical_px,
+ bool active);
+
+ // Shared with the viewport's main FrameUniforms: same cap so the
+ // viewport's clip-plane array and the gizmo visualiser agree on
+ // how many planes can ever be active.
+ static constexpr int kMaxSectionPlanes = 6;
+
+private:
+ bool buildAxisIndicator();
+ bool buildSectionVisualizer();
+ bool buildMarquee();
+
+ WGPUInstance instance_ = nullptr;
+ WGPUDevice device_ = nullptr;
+ WGPUQueue queue_ = nullptr;
+ 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;
+
+ // ---- Section plane gizmos (1 pipeline, dynamic offset per plane) ----
+ WGPUShaderModule section_shader_module_ = nullptr;
+ WGPUBindGroupLayout section_bgl_ = nullptr;
+ WGPUPipelineLayout section_pipeline_layout_ = nullptr;
+ WGPURenderPipeline section_pipeline_ = nullptr;
+ WGPUBuffer section_vertex_buffer_ = nullptr;
+ WGPUBuffer section_uniform_buffer_ = nullptr;
+ WGPUBindGroup section_bind_group_ = nullptr;
+ static constexpr uint32_t kSectionUniformSlotSize = 256;
+
+ // ---- Marquee (fill + outline pipelines, one uniform buffer) ----
+ WGPUShaderModule marquee_shader_module_ = nullptr;
+ WGPUBindGroupLayout marquee_bgl_ = nullptr;
+ WGPUPipelineLayout marquee_pipeline_layout_ = nullptr;
+ WGPURenderPipeline marquee_pipeline_ = nullptr;
+ WGPURenderPipeline marquee_fill_pipeline_ = nullptr;
+ WGPUBuffer marquee_vertex_buffer_ = nullptr;
+ WGPUBuffer marquee_fill_vertex_buffer_ = nullptr;
+ WGPUBuffer marquee_uniform_buffer_ = nullptr;
+ WGPUBindGroup marquee_bind_group_ = nullptr;
+};
+
+#endif // WGPUOVERLAYRENDERER_H
diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
index 4977a6aa60..947de6ef3c 100644
--- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
+++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
@@ -48,8 +48,9 @@
// std140-ish layout: every member naturally 16-aligned, struct stride = 96.
// -----------------------------------------------------------------------------
-// Section-cutting cap. Matches GL ViewportWindow's MaxSectionPlanes.
-static constexpr int kMaxSectionPlanes = 6;
+// Section-cutting cap. Single source of truth lives in WgpuOverlayRenderer
+// so the visualizer and the WGSL clip array agree by construction.
+static constexpr int kMaxSectionPlanes = WgpuOverlayRenderer::kMaxSectionPlanes;
struct FrameUniforms {
float view_proj[16];
@@ -1614,9 +1615,10 @@ bool WgpuViewportWindow::initWgpu() {
if (!buildPipelines()) return false;
if (!buildHizPipeline()) return false;
if (!buildEdgePipeline()) return false;
- if (!buildAxisIndicator()) return false;
- if (!buildSectionVisualizer()) return false;
- if (!buildMarquee()) return false;
+ if (!overlays_.init(instance_, device_, queue_, surface_format_, SAMPLE_COUNT)) {
+ qWarning() << "WgpuOverlayRenderer init failed";
+ return false;
+ }
if (!buildPickPipeline()) return false;
qInfo() << "wgpu init OK; surface format =" << int(surface_format_);
@@ -2166,469 +2168,6 @@ void WgpuViewportWindow::encodeEdgePass(WGPUCommandEncoder enc,
}
// -----------------------------------------------------------------------------
-// Axis indicator: corner gizmo + orbit pivot
-// -----------------------------------------------------------------------------
-//
-// One shader and one 6-vertex unit-cross VBO drive both the camera-orientation
-// gizmo in the bottom-left and the orbit-pivot marker at camera_target. The
-// vertex shader transforms each vertex as `mvp * (origin + position * arm)`
-// so the same primitive serves both:
-//
-// corner: mvp = ortho × lookAt(camera_dir) — camera-orientation only,
-// origin = 0, arm = 1, viewport set to a small corner box.
-// pivot : mvp = main view-proj,
-// origin = camera_target, arm = 30 logical px in world units.
-//
-// Two pipelines: pivot draws inside the main MSAA render pass after geometry
-// so depth interaction is correct; corner draws on the resolved surface after
-// the edge silhouette pass so the laplacian can't darken its lines.
-
-// Shared by every thick-line gizmo (axis indicator, section plane
-// gizmo, future overlays). Provides:
-// - VsOut: rasterizer carry — clip_pos, rgba colour, side_t for AA
-// - thick_line_clip(p_start, p_end, t, side, viewport_size,
-// line_width_px): the actual screen-space expansion.
-// Every vertex carries BOTH endpoints; expanding from
-// `s_end - s_start` (NOT this vertex vs the other) keeps the
-// perpendicular consistent so the quad stays a rectangle and not
-// a bowtie. clip.w is preserved so depth interpolation stays correct.
-// - fs_main: analytical 1-pixel AA via |side_t| + fwidth() —
-// interpolated across the perpendicular, smoothstep over its
-// per-fragment derivative gives a smooth edge regardless of MSAA.
-// Pasted into each shader as a prefix via adjacent string literals.
-#define THICK_LINE_HELPERS_WGSL 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);
-}
-)WGSL"
-
-static const char* AXIS_WGSL = 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";
-
-// Populate `attribs[5]` with the standard thick-line 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`.
-static 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;
-}
-
-bool WgpuViewportWindow::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 so all overlay colours
- // come from one canonical source.
- //
- // Vertex buffer: three positive-axis rays, each expanded into a
- // 4-corner quad (6 vertices in triangle-list order) so the vertex
- // shader can offset by `line_width / 2` pixels in screen space.
- // Per vertex (11 floats = 44 bytes):
- // start (vec3) — the line's start endpoint (same for all 6 verts of an axis)
- // end (vec3) — the line's end endpoint (same for all 6 verts of an axis)
- // col (vec3) — RGB
- // t (f32) — 0 if this vertex sits at `start`, 1 if at `end`
- // side (f32) — -1 or +1, which half of the perpendicular
- //
- // The 6-vertex ordering for the two triangles of each quad is
- // (start,-1) (start,+1) (end,-1) (end,-1) (start,+1) (end,+1)
- // — a standard triangle-list two-tri quad split.
- static const float axis_verts[] = {
- // start end color (RGB — Bonsai decorators) t side
- // ---- +X (decorator red) — start=(0,0,0), end=(1,0,0) ----
- 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 (decorator green) — start=(0,0,0), end=(0,1,0) ----
- 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 (decorator blue) — start=(0,0,0), end=(0,0,1) ----
- 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));
- }
-
- // Uniform buffer: three 256-byte-aligned slots
- // (slot 0 = corner, slot 1 = pivot visible, slot 2 = pivot x-ray)
- // addressed via a dynamic offset on the bind group.
- {
- 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);
- }
-
- // Bind group layout: single uniform with dynamic offset.
- {
- WGPUBindGroupLayoutEntry entry = {};
- entry.binding = 0;
- entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
- entry.buffer.type = WGPUBufferBindingType_Uniform;
- entry.buffer.hasDynamicOffset = 1;
- entry.buffer.minBindingSize = 96; // mat4 + vec3 + f32 + f32 + f32 + vec2 (struct size = 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);
- }
-
- // Pipeline layout.
- {
- 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);
- }
-
- // Bind group: one binding pointing at slot 0 (size = slot size; offset
- // selected dynamically at setBindGroup time).
- {
- 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);
- }
-
- // Shader module (shared between both pipelines).
- {
- WGPUShaderSourceWGSL wgsl_src = {};
- wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
- wgsl_src.code = svFromCStr(AXIS_WGSL);
- 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);
-
- // Standard alpha blend so the corner gizmo can soften over the resolved
- // background and the pivot can fade against scene colour.
- 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 pipelines: MSAA color + depth, both x-ray + visible ----
- // The two pipelines differ only in depthCompare: GreaterEqual lets the
- // x-ray pass land only on pixels where the scene's depth is AT or NEARER
- // than the pivot (so we know the pivot is behind something there); the
- // visible pass uses LessEqual to land where the pivot is in front. We
- // build them in a small helper-like inline block to share state.
- 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 = 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 pipeline: 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_;
-}
-
-// Pack a 256-byte uniform slot. Layout matches the WGSL AxisUniforms
-// struct's natural alignment: mat4 + vec3 + f32 + f32 + f32 + vec2.
-static void packAxisUniform(uint8_t* dst,
- const QMatrix4x4& mvp,
- const QVector3D& origin,
- float arm, float alpha,
- float line_width_px,
- float viewport_w, float viewport_h) {
- std::memset(dst, 0, 256);
- std::memcpy(dst, mvp.constData(), 16 * sizeof(float)); // 0..64 mvp
- float ox = origin.x(), oy = origin.y(), oz = origin.z();
- std::memcpy(dst + 64, &ox, sizeof(float)); // 64..68 origin.x
- std::memcpy(dst + 68, &oy, sizeof(float)); // 68..72 origin.y
- std::memcpy(dst + 72, &oz, sizeof(float)); // 72..76 origin.z
- std::memcpy(dst + 76, &arm, sizeof(float)); // 76..80 arm
- std::memcpy(dst + 80, &alpha, sizeof(float)); // 80..84 alpha
- std::memcpy(dst + 84, &line_width_px, sizeof(float)); // 84..88 line_width_px
- std::memcpy(dst + 88, &viewport_w, sizeof(float)); // 88..92 viewport.x
- std::memcpy(dst + 92, &viewport_h, sizeof(float)); // 92..96 viewport.y
-}
-
-void WgpuViewportWindow::encodePivotIndicator(WGPURenderPassEncoder pass,
- const QMatrix4x4& view_proj) {
- if (!pivot_indicator_visible_ || !axis_pivot_pipeline_
- || !axis_pivot_xray_pipeline_) return;
- if (configured_h_ <= 0) return;
-
- // Arm length = 30 logical px projected into world space at the pivot's
- // distance. world_per_pixel matches GL's renderPivotIndicator math:
- // 2 · d · tan(fovy/2) / viewport_h (ortho's box height collapses to the
- // same formula because we size it from camera_distance at the pivot).
- const float fovy_rad = qDegreesToRadians(camera_fov_y_deg_);
- const float world_per_pixel = camera_distance_ * std::tan(fovy_rad * 0.5f)
- * 2.0f / float(configured_h_);
- const float arm_pixels = 30.0f * float(devicePixelRatio());
- const float arm_world = arm_pixels * world_per_pixel;
-
- const QVector3D origin(camera_target_[0], camera_target_[1], camera_target_[2]);
-
- // Slot 1 = visible (α=1), slot 2 = x-ray (α=0.30). Identical except for
- // alpha — write both so a single bind group can dispatch both draws.
- const float dpr = float(devicePixelRatio());
- const float line_w = 2.5f * dpr;
- const float vw = float(configured_w_);
- const float vh = float(configured_h_);
- uint8_t slot_visible[256];
- uint8_t slot_xray[256];
- packAxisUniform(slot_visible, view_proj, origin, arm_world, 1.00f, line_w, vw, vh);
- packAxisUniform(slot_xray, view_proj, origin, 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);
-
- // Pass 1: dim x-ray for the occluded portion.
- wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_xray_pipeline_);
- wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &xray_off);
- wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
-
- // Pass 2: full-alpha visible portion.
- wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_pipeline_);
- wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &visible_off);
- wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
-}
-
-void WgpuViewportWindow::encodeCornerAxisGizmo(WGPUCommandEncoder enc,
- WGPUTextureView surface_view) {
- if (!axis_corner_pipeline_ || !surface_view) return;
- const int dpr = std::max(1, int(devicePixelRatio()));
- const uint32_t gizmo_size = uint32_t(110 * dpr);
- const uint32_t margin = uint32_t(10 * dpr);
- if (gizmo_size == 0 || configured_w_ <= 0 || configured_h_ <= 0) return;
- // Pin to the BOTTOM-LEFT corner. WebGPU framebuffer y goes top→bottom,
- // so a bottom-left corner sits at y = height - margin - gizmo_size.
- const uint32_t fb_h = uint32_t(configured_h_);
- if (gizmo_size + margin > fb_h) return;
- const uint32_t y = fb_h - margin - gizmo_size;
-
- // Build the gizmo's MVP: a small ortho box looking at the origin from
- // the camera's direction. The eye direction matches GL's renderAxisGizmo
- // exactly (yaw/pitch → unit forward, world-up = +Z). Near the poles the
- // up axis collapses against the look direction, so swap to Y-up there —
- // mirrors buildViewProj's identical fallback for the main projection.
- const float yaw_rad = qDegreesToRadians(camera_yaw_deg_);
- const float pitch_rad = qDegreesToRadians(camera_pitch_deg_);
- const QVector3D eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
- std::cos(pitch_rad) * std::sin(yaw_rad),
- std::sin(pitch_rad));
- const QVector3D world_up = (std::abs(camera_pitch_deg_) >= 89.0f)
- ? QVector3D(0.0f, 1.0f, 0.0f)
- : QVector3D(0.0f, 0.0f, 1.0f);
- QMatrix4x4 gv;
- gv.lookAt(eye_dir * 3.0f, QVector3D(0, 0, 0), world_up);
- QMatrix4x4 gp;
- gp.ortho(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
- // Match the main projection's z-remap so the gizmo's NDC z falls in
- // [0, 1] (WebGPU) rather than [-1, 1] (Qt's GL-style projection).
- QMatrix4x4 z_remap;
- z_remap(2, 2) = 0.5f;
- z_remap(2, 3) = 0.5f;
- const QMatrix4x4 mvp = z_remap * gp * gv;
-
- // line_width and viewport_size are in the gizmo's local viewport (not
- // the framebuffer), because the vertex-shader perpendicular offset is
- // computed in NDC and NDC is per-viewport.
- uint8_t slot[256];
- const float line_w = 2.5f * float(dpr);
- packAxisUniform(slot, mvp, QVector3D(0, 0, 0), 1.0f, 1.0f, line_w,
- float(gizmo_size), float(gizmo_size));
- const uint32_t slot_offset = 0u; // corner lives in slot 0
- wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, slot_offset, slot, sizeof(slot));
-
- WGPURenderPassColorAttachment color = {};
- color.view = surface_view;
- color.loadOp = WGPULoadOp_Load; // preserve what was drawn before
- 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);
-}
-
void WgpuViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_ms) {
if (!pivot_indicator_hide_timer_) {
pivot_indicator_hide_timer_ = new QTimer(this);
@@ -2647,646 +2186,12 @@ void WgpuViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_m
}
requestUpdate();
}
-
-// -----------------------------------------------------------------------------
-// Marquee overlay (drag-rect for box-select)
-// -----------------------------------------------------------------------------
-//
-// 4-segment unit-quad VBO; the vertex shader maps (0..1)² unit coords to NDC
-// via the per-frame uniform (rect.min, rect.max in NDC, both in [-1, 1]
-// with +y up). Reuses thick_line_clip / fs_main from the shared header so
-// the rect has analytical AA and reads cleanly against any background.
-
-static const char* MARQUEE_WGSL = THICK_LINE_HELPERS_WGSL R"WGSL(
-struct MarqueeUniforms {
- rect_min: vec2,
- rect_max: vec2,
- color: vec4,
- viewport_size: vec2,
- line_width_px: f32,
- fill_alpha: f32, // multiplier on color.a for the translucent fill pass
-};
-
-@group(0) @binding(0) var u: MarqueeUniforms;
-
-// Outline (thick line + AA).
-@vertex
-fn vs_main(@location(0) start_uv: vec2,
- @location(1) end_uv: vec2,
- @location(2) t: f32,
- @location(3) side: f32) -> VsOut {
- let p_start = vec4(mix(u.rect_min, u.rect_max, start_uv), 0.0, 1.0);
- let p_end = vec4(mix(u.rect_min, u.rect_max, end_uv), 0.0, 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 = u.color;
- out.side_t = side;
- return out;
-}
-
-// Fill (flat translucent quad). Drawn before the outline so the outline
-// renders on top with full opacity.
-struct VsFillOut {
- @builtin(position) clip_pos: vec4,
-};
-
-@vertex
-fn vs_fill(@location(0) pos_uv: vec2) -> VsFillOut {
- var out: VsFillOut;
- let p = mix(u.rect_min, u.rect_max, pos_uv);
- out.clip_pos = vec4(p, 0.0, 1.0);
- return out;
-}
-
-@fragment
-fn fs_fill() -> @location(0) vec4 {
- return vec4(u.color.xyz, u.color.w * u.fill_alpha);
-}
-)WGSL";
-
-bool WgpuViewportWindow::buildMarquee() {
- // VBO: 4 line segments (top/right/bottom/left of the unit quad),
- // each expanded into a 6-vertex thick-line. Per vertex (6 floats =
- // 24 bytes): start_uv(vec2) + end_uv(vec2) + t(f32) + side(f32).
- struct Seg { std::array s, e; };
- static const Seg segs[] = {
- { {0, 0}, {1, 0} }, // top
- { {1, 0}, {1, 1} }, // right
- { {1, 1}, {0, 1} }, // bottom
- { {0, 1}, {0, 0} }, // left
- };
- std::vector verts;
- verts.reserve(std::size(segs) * 6 * 6);
- auto push_v = [&](const Seg& s, float t, float side) {
- verts.insert(verts.end(), { s.s[0], s.s[1], s.e[0], s.e[1], t, side });
- };
- for (const auto& s : segs) {
- push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
- push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
- }
- {
- WGPUBufferDescriptor bdesc = {};
- bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
- bdesc.size = verts.size() * sizeof(float);
- bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_vbo");
- marquee_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
- wgpuQueueWriteBuffer(queue_, marquee_vertex_buffer_, 0,
- verts.data(), verts.size() * sizeof(float));
- }
- // Fill VBO: 6 verts of a unit-quad triangle list.
- static const float fill_verts[] = {
- 0, 0, 1, 0, 1, 1,
- 0, 0, 1, 1, 0, 1,
- };
- {
- WGPUBufferDescriptor bdesc = {};
- bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
- bdesc.size = sizeof(fill_verts);
- bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_fill_vbo");
- marquee_fill_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
- wgpuQueueWriteBuffer(queue_, marquee_fill_vertex_buffer_, 0,
- fill_verts, sizeof(fill_verts));
- }
- {
- WGPUBufferDescriptor bdesc = {};
- bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
- bdesc.size = 64; // vec2 + vec2 + vec4 + vec2 + f32 + f32 = 48 B + pad
- bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_uniforms");
- marquee_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
- }
- {
- WGPUBindGroupLayoutEntry entry = {};
- entry.binding = 0;
- entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
- entry.buffer.type = WGPUBufferBindingType_Uniform;
- entry.buffer.hasDynamicOffset = 0;
- entry.buffer.minBindingSize = 48;
- WGPUBindGroupLayoutDescriptor bgl_desc = {};
- bgl_desc.entryCount = 1;
- bgl_desc.entries = &entry;
- bgl_desc.label = svFromCStr("ifcviewer-wgpu.marquee_bgl");
- marquee_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
- }
- {
- WGPUPipelineLayoutDescriptor pl_desc = {};
- pl_desc.bindGroupLayoutCount = 1;
- pl_desc.bindGroupLayouts = &marquee_bgl_;
- pl_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pipeline_layout");
- marquee_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
- }
- {
- WGPUBindGroupEntry entry = {};
- entry.binding = 0;
- entry.buffer = marquee_uniform_buffer_;
- entry.offset = 0;
- entry.size = 64;
- WGPUBindGroupDescriptor bg_desc = {};
- bg_desc.layout = marquee_bgl_;
- bg_desc.entryCount = 1;
- bg_desc.entries = &entry;
- bg_desc.label = svFromCStr("ifcviewer-wgpu.marquee_bind_group");
- marquee_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
- }
- {
- WGPUShaderSourceWGSL wgsl_src = {};
- wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
- wgsl_src.code = svFromCStr(MARQUEE_WGSL);
- WGPUShaderModuleDescriptor sm_desc = {};
- sm_desc.nextInChain = &wgsl_src.chain;
- sm_desc.label = svFromCStr("ifcviewer-wgpu.marquee_wgsl");
- marquee_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
- }
-
- // Vertex layout: start_uv(vec2) + end_uv(vec2) + t(f32) + side(f32),
- // stride 24.
- WGPUVertexAttribute attribs[4] = {};
- attribs[0].format = WGPUVertexFormat_Float32x2; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
- attribs[1].format = WGPUVertexFormat_Float32x2; attribs[1].offset = 8; attribs[1].shaderLocation = 1;
- attribs[2].format = WGPUVertexFormat_Float32; attribs[2].offset = 16; attribs[2].shaderLocation = 2;
- attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 20; attribs[3].shaderLocation = 3;
- WGPUVertexBufferLayout vbl = {};
- vbl.arrayStride = 24;
- vbl.stepMode = WGPUVertexStepMode_Vertex;
- vbl.attributeCount = 4;
- 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;
-
- WGPUColorTargetState ct = {};
- ct.format = surface_format_;
- ct.blend = &blend;
- ct.writeMask = WGPUColorWriteMask_All;
-
- WGPUFragmentState frag = {};
- frag.module = marquee_shader_module_;
- frag.entryPoint = svFromCStr("fs_main");
- frag.targetCount = 1;
- frag.targets = &ct;
-
- WGPURenderPipelineDescriptor rp_desc = {};
- rp_desc.layout = marquee_pipeline_layout_;
- rp_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pipeline");
- rp_desc.vertex.module = marquee_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;
- marquee_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
-
- // ---- Fill pipeline (translucent quad, vs_fill / fs_fill) ----
- WGPUVertexAttribute fill_attribs[1] = {};
- fill_attribs[0].format = WGPUVertexFormat_Float32x2; // pos_uv
- fill_attribs[0].offset = 0;
- fill_attribs[0].shaderLocation = 0;
- WGPUVertexBufferLayout fill_vbl = {};
- fill_vbl.arrayStride = 8;
- fill_vbl.stepMode = WGPUVertexStepMode_Vertex;
- fill_vbl.attributeCount = 1;
- fill_vbl.attributes = fill_attribs;
-
- WGPUFragmentState fill_frag = {};
- fill_frag.module = marquee_shader_module_;
- fill_frag.entryPoint = svFromCStr("fs_fill");
- fill_frag.targetCount = 1;
- fill_frag.targets = &ct;
-
- WGPURenderPipelineDescriptor fill_rp_desc = rp_desc;
- fill_rp_desc.label = svFromCStr("ifcviewer-wgpu.marquee_fill_pipeline");
- fill_rp_desc.vertex.entryPoint = svFromCStr("vs_fill");
- fill_rp_desc.vertex.bufferCount = 1;
- fill_rp_desc.vertex.buffers = &fill_vbl;
- fill_rp_desc.fragment = &fill_frag;
- marquee_fill_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &fill_rp_desc);
-
- return marquee_pipeline_ != nullptr && marquee_fill_pipeline_ != nullptr;
-}
-
-void WgpuViewportWindow::encodeMarquee(WGPUCommandEncoder enc,
- WGPUTextureView surface_view) {
- if (!marquee_pipeline_ || !surface_view) return;
- if (!box_select_active_) return;
- if (configured_w_ <= 0 || configured_h_ <= 0) return;
-
- // Logical-pixel rect → NDC. Qt mouse y goes top→bottom; NDC y goes
- // bottom→top, hence the (1 - 2y/h) flip.
- const float w = float(configured_w_);
- const float h = float(configured_h_);
- const float dpr = float(std::max(1, int(devicePixelRatio())));
- const float lx0 = float(std::min(box_select_start_pos_.x(),
- box_select_current_pos_.x())) * dpr;
- const float ly0 = float(std::min(box_select_start_pos_.y(),
- box_select_current_pos_.y())) * dpr;
- const float lx1 = float(std::max(box_select_start_pos_.x(),
- box_select_current_pos_.x())) * dpr;
- const float ly1 = float(std::max(box_select_start_pos_.y(),
- box_select_current_pos_.y())) * dpr;
- if (lx1 <= lx0 || ly1 <= ly0) return;
-
- const float nx0 = (lx0 / w) * 2.0f - 1.0f;
- const float nx1 = (lx1 / w) * 2.0f - 1.0f;
- // y: pixel 0 is top, so NDC y = 1 - 2*py/h
- const float ny_top = 1.0f - 2.0f * ly0 / h;
- const float ny_bottom = 1.0f - 2.0f * ly1 / h;
- // The vs shader expects rect_max y > rect_min y in NDC; the rect_uv
- // y = 0 maps to rect_min y. Top corner gets uv.y = 0, bottom gets
- // uv.y = 1. So rect_min.y = ny_top, rect_max.y = ny_bottom.
- // Bonsai decorator_color_special (axis +Z blue): 0.157, 0.565, 1.000.
- // Outline alpha 0.95; fill_alpha (multiplied onto that) gives ~0.15
- // alpha in the fill so the underlying scene reads through.
- float uniforms[16] = {};
- uniforms[0] = nx0; uniforms[1] = ny_top; // rect_min @ 0
- uniforms[2] = nx1; uniforms[3] = ny_bottom; // rect_max @ 8
- uniforms[4] = 0.157f; uniforms[5] = 0.565f; // color.rg @ 16
- uniforms[6] = 1.000f; uniforms[7] = 0.95f; // color.ba
- uniforms[8] = w; uniforms[9] = h; // viewport_size @ 32
- uniforms[10] = 3.0f * dpr; // line_width_px @ 40
- uniforms[11] = 0.20f; // fill_alpha @ 44
- wgpuQueueWriteBuffer(queue_, marquee_uniform_buffer_, 0,
- uniforms, 12 * sizeof(float));
-
- WGPURenderPassColorAttachment color = {};
- color.view = surface_view;
- color.loadOp = WGPULoadOp_Load;
- color.storeOp = WGPUStoreOp_Store;
- color.clearValue = { 0, 0, 0, 1 };
- color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
-
- WGPURenderPassDescriptor pass_desc = {};
- pass_desc.colorAttachmentCount = 1;
- pass_desc.colorAttachments = &color;
- pass_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pass");
-
- WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
- wgpuRenderPassEncoderSetBindGroup(pass, 0, marquee_bind_group_, 0, nullptr);
-
- // Pass 1: translucent fill (underneath the outline).
- wgpuRenderPassEncoderSetPipeline(pass, marquee_fill_pipeline_);
- wgpuRenderPassEncoderSetVertexBuffer(pass, 0, marquee_fill_vertex_buffer_,
- 0, WGPU_WHOLE_SIZE);
- wgpuRenderPassEncoderDraw(pass, 6, 1, 0, 0);
-
- // Pass 2: thick-line outline.
- wgpuRenderPassEncoderSetPipeline(pass, marquee_pipeline_);
- wgpuRenderPassEncoderSetVertexBuffer(pass, 0, marquee_vertex_buffer_, 0,
- WGPU_WHOLE_SIZE);
- wgpuRenderPassEncoderDraw(pass, 24, 1, 0, 0);
-
- wgpuRenderPassEncoderEnd(pass);
- wgpuRenderPassEncoderRelease(pass);
-}
-
-void WgpuViewportWindow::releaseMarquee() {
- if (marquee_bind_group_) { wgpuBindGroupRelease(marquee_bind_group_); marquee_bind_group_ = nullptr; }
- if (marquee_pipeline_) { wgpuRenderPipelineRelease(marquee_pipeline_); marquee_pipeline_ = nullptr; }
- if (marquee_fill_pipeline_) { wgpuRenderPipelineRelease(marquee_fill_pipeline_); marquee_fill_pipeline_ = nullptr; }
- if (marquee_shader_module_) { wgpuShaderModuleRelease(marquee_shader_module_); marquee_shader_module_ = nullptr; }
- if (marquee_pipeline_layout_) { wgpuPipelineLayoutRelease(marquee_pipeline_layout_); marquee_pipeline_layout_ = nullptr; }
- if (marquee_bgl_) { wgpuBindGroupLayoutRelease(marquee_bgl_); marquee_bgl_ = nullptr; }
- if (marquee_uniform_buffer_) { wgpuBufferRelease(marquee_uniform_buffer_); marquee_uniform_buffer_ = nullptr; }
- if (marquee_vertex_buffer_) { wgpuBufferRelease(marquee_vertex_buffer_); marquee_vertex_buffer_ = nullptr; }
- if (marquee_fill_vertex_buffer_) { wgpuBufferRelease(marquee_fill_vertex_buffer_); marquee_fill_vertex_buffer_ = nullptr; }
- if (box_pick_staging_buffer_) { wgpuBufferRelease(box_pick_staging_buffer_); box_pick_staging_buffer_ = nullptr; }
- box_pick_staging_capacity_ = 0;
-}
-
-// -----------------------------------------------------------------------------
-// Section plane visualisation
-// -----------------------------------------------------------------------------
-//
-// Each active plane is drawn as a quad sized to span the scene's AABB so the
-// cut is visible at any zoom. The quad is a unit square in plane-local space;
-// the vertex shader builds tangent / bitangent from the plane normal and
-// places the quad at u_origin. Two draws per plane share one pipeline:
-// a back-face fill at lower alpha gives an "occluded" hint when the camera
-// sees the back of the plane (matches what GL's plane gizmo does visually).
-// 6 active-plane × 1 quad × 6 verts = 36 verts max per frame, trivial.
-
-// GL's section gizmo (2 × 2 m wireframe quad outline + arrow shaft + 4-line
-// arrow head) rendered as thick lines so it stays visible against busy
-// BIM geometry. Shares VsOut, thick_line_clip and fs_main with the axis
-// indicator via THICK_LINE_HELPERS_WGSL.
-static const char* SECTION_WGSL = THICK_LINE_HELPERS_WGSL R"WGSL(
-struct SectionUniforms {
- mvp: mat4x4,
- origin: vec3,
- half_size: f32,
- tangent: vec3,
- line_width_px: f32,
- bitangent: vec3,
- _pad1: f32,
- normal: vec3,
- _pad2: f32,
- tint: vec4,
- viewport_size: vec2,
- _pad3: vec2,
-};
-
-@group(0) @binding(0) var u: SectionUniforms;
-
-fn plane_to_world(p: vec3) -> vec3 {
- return u.origin + (u.tangent * p.x + u.bitangent * p.y + u.normal * p.z)
- * u.half_size;
-}
-
-@vertex
-fn vs_main(@location(0) start_local: vec3,
- @location(1) end_local: vec3,
- @location(2) col: vec3,
- @location(3) t: f32,
- @location(4) side: f32) -> VsOut {
- let p_start = u.mvp * vec4(plane_to_world(start_local), 1.0);
- let p_end = u.mvp * vec4(plane_to_world(end_local), 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.tint.xyz, u.tint.w);
- out.side_t = side;
- return out;
-}
-)WGSL";
-
-bool WgpuViewportWindow::buildSectionVisualizer() {
- // 9 line segments (4 quad-outline + 1 arrow shaft + 4 arrow head),
- // each expanded into a 6-vertex thick-line quad by the vertex shader.
- // Per vertex (11 floats = 44 B):
- // start_local (vec3) — segment's start point in plane-local space
- // end_local (vec3) — segment's end point in plane-local space
- // col (vec3) — RGB
- // t (f32) — 0 at start, 1 at end
- // side (f32) — -1 or +1, which half of the perpendicular
- struct Seg {
- std::array s, e;
- std::array c;
- };
- // Entire gizmo uses Bonsai's decorator_color_error red so it reads
- // against any geometry. White quad + yellow arrow (GL's defaults)
- // disappeared into light surfaces; one consistent saturated red is
- // both more visible and visually identifies "this is a tool overlay".
- static constexpr std::array kSectionRed = {1.000f, 0.200f, 0.322f};
- static const Seg segs[] = {
- // ---- quad outline ----
- { {-1, -1, 0}, { 1, -1, 0}, kSectionRed },
- { { 1, -1, 0}, { 1, 1, 0}, kSectionRed },
- { { 1, 1, 0}, {-1, 1, 0}, kSectionRed },
- { {-1, 1, 0}, {-1, -1, 0}, kSectionRed },
- // ---- arrow shaft along +n ----
- { { 0, 0, 0}, { 0, 0, 1}, kSectionRed },
- // ---- arrow head: 4 diagonals from tip to ring at z = 0.78 ----
- { { 0, 0, 1}, {-0.18f, 0, 0.78f}, kSectionRed },
- { { 0, 0, 1}, { 0.18f, 0, 0.78f}, kSectionRed },
- { { 0, 0, 1}, { 0, -0.18f, 0.78f}, kSectionRed },
- { { 0, 0, 1}, { 0, 0.18f, 0.78f}, kSectionRed },
- };
- constexpr size_t kVertsPerSegment = 6; // (start,-1) (start,+1) (end,-1) (end,-1) (start,+1) (end,+1)
- constexpr size_t kFloatsPerVertex = 11; // start.xyz, end.xyz, col.xyz, t, side
- std::vector verts;
- verts.reserve(std::size(segs) * kVertsPerSegment * kFloatsPerVertex);
- auto push_v = [&](const Seg& s, float t, float side) {
- verts.insert(verts.end(), { s.s[0], s.s[1], s.s[2],
- s.e[0], s.e[1], s.e[2],
- s.c[0], s.c[1], s.c[2],
- t, side });
- };
- for (const auto& s : segs) {
- push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
- push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
- }
- {
- WGPUBufferDescriptor bdesc = {};
- bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
- bdesc.size = verts.size() * sizeof(float);
- bdesc.label = svFromCStr("ifcviewer-wgpu.section_gizmo_vbo");
- section_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
- wgpuQueueWriteBuffer(queue_, section_vertex_buffer_, 0,
- verts.data(), verts.size() * sizeof(float));
- }
-
- // Uniform buffer: one slot per plane (cap at kMaxSectionPlanes).
- {
- WGPUBufferDescriptor bdesc = {};
- bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
- bdesc.size = uint64_t(kMaxSectionPlanes) * kSectionUniformSlotSize;
- bdesc.label = svFromCStr("ifcviewer-wgpu.section_uniforms");
- section_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
- }
-
- // Bind group layout: single uniform with dynamic offset (slot index ×
- // 256 chosen at setBindGroup time).
- {
- WGPUBindGroupLayoutEntry entry = {};
- entry.binding = 0;
- entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
- entry.buffer.type = WGPUBufferBindingType_Uniform;
- entry.buffer.hasDynamicOffset = 1;
- entry.buffer.minBindingSize = 160; // mat4 + 4×(vec3+pad) + vec4 + vec2 + pad = 160 B
- WGPUBindGroupLayoutDescriptor bgl_desc = {};
- bgl_desc.entryCount = 1;
- bgl_desc.entries = &entry;
- bgl_desc.label = svFromCStr("ifcviewer-wgpu.section_bgl");
- section_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
- }
- {
- WGPUPipelineLayoutDescriptor pl_desc = {};
- pl_desc.bindGroupLayoutCount = 1;
- pl_desc.bindGroupLayouts = §ion_bgl_;
- pl_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline_layout");
- section_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
- }
- {
- WGPUBindGroupEntry entry = {};
- entry.binding = 0;
- entry.buffer = section_uniform_buffer_;
- entry.offset = 0;
- entry.size = kSectionUniformSlotSize;
- WGPUBindGroupDescriptor bg_desc = {};
- bg_desc.layout = section_bgl_;
- bg_desc.entryCount = 1;
- bg_desc.entries = &entry;
- bg_desc.label = svFromCStr("ifcviewer-wgpu.section_bind_group");
- section_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
- }
- {
- WGPUShaderSourceWGSL wgsl_src = {};
- wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
- wgsl_src.code = svFromCStr(SECTION_WGSL);
- WGPUShaderModuleDescriptor sm_desc = {};
- sm_desc.nextInChain = &wgsl_src.chain;
- sm_desc.label = svFromCStr("ifcviewer-wgpu.section_wgsl");
- section_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;
-
- WGPUColorTargetState ct = {};
- ct.format = surface_format_;
- ct.blend = &blend;
- ct.writeMask = WGPUColorWriteMask_All;
-
- WGPUFragmentState frag = {};
- frag.module = section_shader_module_;
- frag.entryPoint = svFromCStr("fs_main");
- frag.targetCount = 1;
- frag.targets = &ct;
-
- // Depth: respect scene depth so half the plane is behind opaque
- // geometry (giving the "this is where it cuts" hint), but don't
- // write depth ourselves so further translucent draws aren't blocked.
- WGPUDepthStencilState depth = {};
- depth.format = WGPUTextureFormat_Depth32Float;
- depth.depthWriteEnabled = WGPUOptionalBool_False;
- depth.depthCompare = WGPUCompareFunction_LessEqual;
- depth.stencilFront.compare = WGPUCompareFunction_Always;
- depth.stencilBack.compare = WGPUCompareFunction_Always;
-
- WGPURenderPipelineDescriptor rp_desc = {};
- rp_desc.layout = section_pipeline_layout_;
- rp_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline");
- rp_desc.vertex.module = section_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 = SAMPLE_COUNT;
- rp_desc.multisample.mask = 0xFFFFFFFFu;
- section_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
-
- return section_pipeline_ != nullptr;
-}
-
-// Pack a 256-byte uniform slot for one section plane. Layout matches WGSL
-// SectionUniforms: mat4 + 4 × (vec3 + scalar pad) + vec4 + vec2 + 8 B pad
-// = 160 B used.
-static void packSectionUniform(uint8_t* dst,
- const QMatrix4x4& mvp,
- const QVector3D& origin, float half_size,
- const QVector3D& tangent, float line_width_px,
- const QVector3D& bitangent,
- const QVector3D& normal,
- float r, float g, float b, float a,
- float viewport_w, float viewport_h) {
- std::memset(dst, 0, 256);
- std::memcpy(dst, mvp.constData(), 16 * sizeof(float));
- auto put_vec3_pad = [&](size_t off, const QVector3D& v, float pad_val) {
- float vx = v.x(), vy = v.y(), vz = v.z();
- std::memcpy(dst + off + 0, &vx, sizeof(float));
- std::memcpy(dst + off + 4, &vy, sizeof(float));
- std::memcpy(dst + off + 8, &vz, sizeof(float));
- std::memcpy(dst + off + 12, &pad_val, sizeof(float));
- };
- put_vec3_pad(64, origin, half_size); // 64..80
- put_vec3_pad(80, tangent, line_width_px); // 80..96
- put_vec3_pad(96, bitangent, 0.0f); // 96..112
- put_vec3_pad(112, normal, 0.0f); // 112..128
- float tint[4] = { r, g, b, a };
- std::memcpy(dst + 128, tint, sizeof(tint)); // 128..144
- std::memcpy(dst + 144, &viewport_w, sizeof(float)); // 144..148
- std::memcpy(dst + 148, &viewport_h, sizeof(float)); // 148..152
-}
-
-void WgpuViewportWindow::encodeSectionPlanes(WGPURenderPassEncoder pass,
- const QMatrix4x4& view_proj) {
- if (!section_pipeline_ || section_planes_.empty()) return;
-
- wgpuRenderPassEncoderSetPipeline(pass, section_pipeline_);
- wgpuRenderPassEncoderSetVertexBuffer(pass, 0, section_vertex_buffer_, 0,
- WGPU_WHOLE_SIZE);
-
- const int n = std::min(int(section_planes_.size()), kMaxSectionPlanes);
- for (int i = 0; i < n; ++i) {
- const SectionPlane& p = section_planes_[i];
-
- // Stable in-plane basis: anyOrthogonal picks the world axis least
- // parallel to n so the cross-product stays well-conditioned at
- // any orientation. tangent → "u" axis, bitangent → "v" axis.
- QVector3D nn = p.n.normalized();
- const float ax = std::abs(nn.x()), ay = std::abs(nn.y()), az = std::abs(nn.z());
- QVector3D seed = (ax < ay && ax < az) ? QVector3D(1, 0, 0)
- : (ay < az) ? QVector3D(0, 1, 0)
- : QVector3D(0, 0, 1);
- QVector3D tangent = QVector3D::crossProduct(nn, seed);
- if (tangent.lengthSquared() < 1e-12f) tangent = QVector3D(1, 0, 0);
- tangent.normalize();
- QVector3D bitangent = QVector3D::crossProduct(nn, tangent).normalized();
-
- // Fixed 1 m half-size matches GL's renderSectionPlanes constant
- // (kHalfSize = 1.0). Per-plane visual_radius from the picked AABB
- // is no longer used here — the GL gizmo is a small marker, not a
- // sheet sized to the cut subject.
- const float half_size = 1.0f;
- const float dpr = float(std::max(1, int(devicePixelRatio())));
- const float line_w = 5.0f * dpr;
- const float vw = float(configured_w_);
- const float vh = float(configured_h_);
-
- uint8_t slot[256];
- // Neutral tint — actual colours come from the per-vertex VBO
- // (white quad outline + red arrow). Tint stays available for a
- // future "selected" multiplier; alpha controls the whole gizmo's
- // opacity.
- packSectionUniform(slot, view_proj, p.origin, half_size,
- tangent, line_w, bitangent, nn,
- 1.0f, 1.0f, 1.0f, 1.0f,
- vw, vh);
- const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlotSize;
- wgpuQueueWriteBuffer(queue_, section_uniform_buffer_,
- slot_offset, slot, sizeof(slot));
-
- wgpuRenderPassEncoderSetBindGroup(pass, 0, section_bind_group_,
- 1, &slot_offset);
- // 9 segments × 6 verts (2 triangles each) = 54 vertices.
- wgpuRenderPassEncoderDraw(pass, 54, 1, 0, 0);
- }
-}
-
-void WgpuViewportWindow::releaseSectionVisualizer() {
- if (section_bind_group_) { wgpuBindGroupRelease(section_bind_group_); section_bind_group_ = nullptr; }
- if (section_pipeline_) { wgpuRenderPipelineRelease(section_pipeline_); section_pipeline_ = nullptr; }
- if (section_shader_module_) { wgpuShaderModuleRelease(section_shader_module_); section_shader_module_ = nullptr; }
- if (section_pipeline_layout_){ wgpuPipelineLayoutRelease(section_pipeline_layout_); section_pipeline_layout_ = nullptr; }
- if (section_bgl_) { wgpuBindGroupLayoutRelease(section_bgl_); section_bgl_ = nullptr; }
- if (section_uniform_buffer_) { wgpuBufferRelease(section_uniform_buffer_); section_uniform_buffer_ = nullptr; }
- if (section_vertex_buffer_) { wgpuBufferRelease(section_vertex_buffer_); section_vertex_buffer_ = nullptr; }
-}
-
void WgpuViewportWindow::releaseEdgeResources() {
if (edge_bind_group_) { wgpuBindGroupRelease(edge_bind_group_); edge_bind_group_ = nullptr; }
if (edge_pipeline_) { wgpuRenderPipelineRelease(edge_pipeline_); edge_pipeline_ = nullptr; }
if (edge_shader_module_) { wgpuShaderModuleRelease(edge_shader_module_);edge_shader_module_ = nullptr; }
if (edge_pipeline_layout_) { wgpuPipelineLayoutRelease(edge_pipeline_layout_); edge_pipeline_layout_ = nullptr; }
if (edge_bgl_) { wgpuBindGroupLayoutRelease(edge_bgl_); edge_bgl_ = nullptr; }
-
- 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; }
}
// -----------------------------------------------------------------------------
@@ -3898,7 +2803,7 @@ bool WgpuViewportWindow::addSectionPlaneAtSurface(const QVector3D& point,
const QVector3D eye_dir = eye - point;
if (QVector3D::dotProduct(n, eye_dir) < 0.0f) n = -n;
- SectionPlane p;
+ WgpuSectionPlane p;
p.n = n;
p.origin = point;
p.d = -QVector3D::dotProduct(n, point);
@@ -3953,13 +2858,14 @@ int WgpuViewportWindow::hitTestSectionGizmo(int x, int y) const {
int best = -1;
float best_d2 = grab_px * grab_px;
for (int i = 0; i < int(section_planes_.size()); ++i) {
- const SectionPlane& p = section_planes_[i];
+ const WgpuSectionPlane& p = section_planes_[i];
QVector2D s_origin, s_tip;
if (!projectWorldToLogicalScreen(vp, p.origin,
w, h, s_origin)) continue;
// The gizmo's arrow extends along +n by exactly 1 m in world
- // space — encodeSectionPlanes uses half_size = 1.0 to scale a
- // plane-local arrow tip at z = 1. Mirror that here.
+ // space — WgpuOverlayRenderer::encodeSectionGizmos uses
+ // half_size = 1.0 to scale a plane-local arrow tip at z = 1.
+ // Mirror that here.
if (!projectWorldToLogicalScreen(vp, p.origin + p.n * 1.0f,
w, h, s_tip)) continue;
const QVector2D q{float(x), float(y)};
@@ -3979,7 +2885,7 @@ void WgpuViewportWindow::updateSectionDrag(int x, int y) {
if (!section_drag_active_) return;
if (section_drag_index_ < 0
|| section_drag_index_ >= int(section_planes_.size())) return;
- SectionPlane& p = section_planes_[section_drag_index_];
+ WgpuSectionPlane& p = section_planes_[section_drag_index_];
const int w = width();
const int h = height();
@@ -5011,18 +3917,30 @@ void WgpuViewportWindow::render() {
}
}
+ // Snapshot the per-frame inputs every overlay needs. Built once and
+ // passed by const-ref so WgpuOverlayRenderer never reaches back into
+ // this viewport.
+ WgpuOverlayFrame overlay_frame;
+ overlay_frame.view_proj = vp_this_frame;
+ overlay_frame.camera_target = QVector3D(camera_target_[0],
+ camera_target_[1],
+ camera_target_[2]);
+ overlay_frame.camera_distance = camera_distance_;
+ overlay_frame.camera_yaw_deg = camera_yaw_deg_;
+ overlay_frame.camera_pitch_deg = camera_pitch_deg_;
+ overlay_frame.camera_fov_y_deg = camera_fov_y_deg_;
+ overlay_frame.viewport_w_px = int(width() * devicePixelRatio());
+ overlay_frame.viewport_h_px = int(height() * devicePixelRatio());
+ overlay_frame.device_pixel_ratio = int(devicePixelRatio());
+
// Section planes — translucent overlay quads showing where each
- // active clip plane cuts. Drawn inside the main MSAA pass with
- // depth-LessEqual + writeMask off so they participate in depth tests
- // (half hides behind closer geometry) without depth-blocking further
- // overlays. Discard inside the shaders honours the clip itself, so
- // the visible portion of the quad is always the kept-side region.
- encodeSectionPlanes(pass, vp_this_frame);
+ // active clip plane cuts. Drawn inside the main MSAA pass.
+ overlays_.encodeSectionGizmos(pass, overlay_frame, section_planes_);
// Pivot indicator. Encoded inside the main MSAA pass after geometry so
// depth interaction is correct — the indicator vanishes behind closer
// surfaces. Visibility is driven by orbit/wheel UI handlers.
- encodePivotIndicator(pass, vp_this_frame);
+ overlays_.encodePivot(pass, overlay_frame, pivot_indicator_visible_);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
@@ -5036,12 +3954,15 @@ void WgpuViewportWindow::render() {
// Corner axis gizmo. Encoded after the edge pass on the resolved
// surface, so the laplacian can't darken its lines or its background.
- encodeCornerAxisGizmo(enc, view);
+ overlays_.encodeCornerAxis(enc, view, overlay_frame);
// Marquee box-select drag rect (visible only while a drag is active).
// Drawn on the resolved surface so the rect outline isn't affected by
// the edge silhouette pass.
- encodeMarquee(enc, view);
+ overlays_.encodeMarquee(enc, view, overlay_frame,
+ box_select_start_pos_,
+ box_select_current_pos_,
+ box_select_active_);
// ---- HiZ: resolve MSAA depth → small single-sample → ping-pong slot
int hiz_submitted_slot = -1;
@@ -6580,7 +5501,7 @@ void WgpuViewportWindow::updateFrameUniforms() {
const int n = std::min(int(section_planes_.size()), kMaxSectionPlanes);
u.clip_count = n;
for (int i = 0; i < n; ++i) {
- const SectionPlane& p = section_planes_[i];
+ const WgpuSectionPlane& p = section_planes_[i];
u.clip_planes[i][0] = p.n.x();
u.clip_planes[i][1] = p.n.y();
u.clip_planes[i][2] = p.n.z();
@@ -7502,8 +6423,7 @@ void WgpuViewportWindow::shutdown() {
releaseMsaaColorTexture();
releaseHizResources();
releaseEdgeResources();
- releaseSectionVisualizer();
- releaseMarquee();
+ overlays_.destroy();
releasePickResources();
if (frame_bind_group_) { wgpuBindGroupRelease(frame_bind_group_); frame_bind_group_ = nullptr; }
diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h
index 6171ad46a6..edb51cc238 100644
--- a/src/ifcviewer-wgpu/WgpuViewportWindow.h
+++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h
@@ -40,6 +40,7 @@
#include "SidecarCache.h"
#include "WgpuBufferPool.h"
#include "WgpuModelGpuData.h"
+#include "WgpuOverlayRenderer.h"
#include "WgpuSelectionState.h"
#include "WgpuStreamingThread.h"
#include "WgpuVisibilityState.h"
@@ -220,18 +221,11 @@ private:
bool buildHizPipeline();
bool buildEdgePipeline();
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
- bool buildAxisIndicator();
- // Encode the pivot indicator inside the main MSAA render pass — runs
- // after geometry so depth interaction is correct.
- void encodePivotIndicator(WGPURenderPassEncoder pass,
- const QMatrix4x4& view_proj);
- // Encode the corner gizmo on the resolved single-sample surface,
- // after the edge silhouette pass. Uses setViewport for the corner box.
- void encodeCornerAxisGizmo(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.
+ // drag callers pass 0 and toggle manually on press/release. The
+ // actual gizmo rendering lives in WgpuOverlayRenderer — this just
+ // manages the UI-side visibility timer.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
void releaseEdgeResources();
@@ -450,46 +444,17 @@ private:
WGPUBindGroup edge_bind_group_ = nullptr;
bool edges_enabled_ = true;
- // Axis indicator overlay — drives both the corner gizmo and the
- // orbit pivot indicator from the SAME 6-vertex unit cross + shader.
- // One uniform buffer with three 256-byte-aligned slots
- // (slot 0 = corner, slot 1 = pivot visible, slot 2 = pivot x-ray),
- // one bind group with a dynamic offset, three pipelines that differ
- // only in render-target / depth setup:
- // axis_pivot_pipeline_: MSAA + depth LessEqual (visible α=1)
- // axis_pivot_xray_pipeline_: MSAA + depth GreaterEqual (occluded α≈0.3)
- // axis_corner_pipeline_: resolved, no depth, sampleCount=1
- // The pivot's x-ray pass renders first so the visible pass overdraws
- // it where geometry isn't in the way. Matches GL's renderPivotIndicator.
- 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;
- // Marquee overlay (drag-rect for box-select). Renders on the resolved
- // surface after the corner gizmo. Uses a 4-segment unit-quad VBO; the
- // shader maps unit coords to NDC via per-frame uniform (rect min/max
- // in NDC + colour + viewport + line width).
- WGPUShaderModule marquee_shader_module_ = nullptr;
- WGPUBindGroupLayout marquee_bgl_ = nullptr;
- WGPUPipelineLayout marquee_pipeline_layout_ = nullptr;
- WGPURenderPipeline marquee_pipeline_ = nullptr; // outline (thick line + AA)
- WGPURenderPipeline marquee_fill_pipeline_ = nullptr; // translucent quad fill
- WGPUBuffer marquee_vertex_buffer_ = nullptr; // outline geometry
- WGPUBuffer marquee_fill_vertex_buffer_ = nullptr; // 6-vert quad
- WGPUBuffer marquee_uniform_buffer_ = nullptr;
- WGPUBindGroup marquee_bind_group_ = nullptr;
- bool buildMarquee();
- void encodeMarquee(WGPUCommandEncoder enc, WGPUTextureView surface_view);
- void releaseMarquee();
- WGPUBuffer axis_vertex_buffer_ = nullptr; // 6 × 24 B
- WGPUBuffer axis_uniform_buffer_ = nullptr; // 3 × 256 B slots
- WGPUBindGroup axis_bind_group_ = nullptr; // dynamic-offset
- static constexpr uint32_t kAxisUniformSlotSize = 256;
+ // 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 WgpuOverlayFrame each frame and asks the renderer to
+ // encode each overlay; see WgpuOverlayRenderer.h.
+ WgpuOverlayRenderer overlays_;
+
// Pick pass (stage 4). Single-sample R32UInt target + depth, vertex-
// pulled from the same visible_draws / instances buffers as the main
// pass — pick fragment outputs the instance's object_id. The pick
@@ -510,18 +475,11 @@ private:
int pick_w_ = 0;
int pick_h_ = 0;
- // Section-cutting state. Each plane is (n, d) with normal n in world
- // space and signed distance d = -dot(n, point_on_plane); a point P is
- // on the kept side iff dot(n, P) + d <= 0. The vector mirrors GL's
- // ViewportWindow::section_planes_.
- struct SectionPlane {
- QVector3D n; // unit normal
- float d; // -dot(n, origin)
- QVector3D origin; // surface point at the moment the plane was added
- float visual_radius; // half-extent for the gizmo quad (set from the picked instance's AABB diagonal)
- };
- std::vector section_planes_;
- bool section_tool_active_ = false;
+ // Section-cutting state. WgpuSectionPlane lives in WgpuOverlayRenderer.h
+ // because the visualiser reads it; the viewport owns the authoritative
+ // vector that the section tool mutates.
+ std::vector section_planes_;
+ bool section_tool_active_ = false;
// Marquee box-select. Armed on LMB press (when no other tool consumes
// the click), becomes active after the cursor moves past
@@ -553,25 +511,6 @@ private:
// along that direction.
void updateSectionDrag(int x, int y);
- // Section plane visualisation. Renders one translucent quad per active
- // plane, sized to the scene AABB so the cut is visible at any zoom.
- // Built once (unit quad in plane-local space), oriented per-plane in
- // the vertex shader from u_origin + tangent/bitangent (derived from
- // the plane normal). Two passes: a back-facing fill (alpha 0.18) for
- // the "behind geometry" hint plus a front-facing fill (alpha 0.35).
- WGPUShaderModule section_shader_module_ = nullptr;
- WGPUBindGroupLayout section_bgl_ = nullptr;
- WGPUPipelineLayout section_pipeline_layout_ = nullptr;
- WGPURenderPipeline section_pipeline_ = nullptr;
- WGPUBuffer section_vertex_buffer_ = nullptr;
- WGPUBuffer section_uniform_buffer_ = nullptr;
- WGPUBindGroup section_bind_group_ = nullptr;
- static constexpr uint32_t kSectionUniformSlotSize = 256;
- bool buildSectionVisualizer();
- void encodeSectionPlanes(WGPURenderPassEncoder pass,
- const QMatrix4x4& view_proj);
- void releaseSectionVisualizer();
-
enum class HizSlotState : uint8_t { Idle, Mapping, Mapped };
static constexpr int HIZ_SLOTS = 2;
WGPUBuffer hiz_staging_buffers_[HIZ_SLOTS] = { nullptr, nullptr };