From c9f269a0e535a2d2a51eadb0712cd7a9c3a8130b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 3 Aug 2026 18:07:52 +1000 Subject: [PATCH] Draw a silhouette outline around the selection The renderer tints the selection blue, which says nothing about an object that is already blue. Add a halo drawn just outside the selected objects instead: a fixed colour against the background, so it reads whatever the element is painted. The mask pass reuses the main shader module and pipeline layout and shares the main depth buffer read-only, so the halo follows the selection as visible -- an occluded object contributes nothing. A separable dilation widens the mask into inner and outer rings, composited after the edge pass so the edge multiply does not darken it. On by default; IfcViewer.setSelectionOutline(false) gets the tint alone. Co-Authored-By: Claude Opus 5 (1M context) --- src/ifcviewer-web/CMakeLists.txt | 2 +- src/ifcviewer-web/main_web.cpp | 9 + src/ifcviewer-web/tests/outline.spec.mjs | 45 +++ src/ifcviewer-web/web/ifcviewer.js | 11 + src/ifcviewer/ViewportCore.cpp | 490 +++++++++++++++++++++++ src/ifcviewer/ViewportCore.h | 63 +++ 6 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 src/ifcviewer-web/tests/outline.spec.mjs diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 13678a7ac0..56d4b92aa6 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -114,7 +114,7 @@ target_link_options(IfcViewerWeb PRIVATE # EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't # add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model # URL) to load_sidecar_from_url_c without manual heap marshalling. - "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c']" + "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c']" # ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c, # and the nav-preset name to ifcv_set_nav_preset_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 99cdd9c0af..2c71b7fd4d 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -592,6 +592,14 @@ extern "C" EMSCRIPTEN_KEEPALIVE void show_all_c() { if (g_app && g_app-> extern "C" EMSCRIPTEN_KEEPALIVE void hide_all_c() { if (g_app && g_app->ready) g_app->core.hideAll(); } extern "C" EMSCRIPTEN_KEEPALIVE void toggle_xray_c() { if (g_app && g_app->ready) g_app->core.toggleXray(); } extern "C" EMSCRIPTEN_KEEPALIVE int xray_is_active_c() { return (g_app && g_app->ready && g_app->core.xrayActive()) ? 1 : 0; } +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_selection_outline_c(int on) { + if (!g_app || !g_app->ready) return; + g_app->core.setSelectionOutlineEnabled(on != 0); + g_app->host.requestFrame(); +} +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_selection_outline_is_on_c() { + return (g_app && g_app->ready && g_app->core.selectionOutlineEnabled()) ? 1 : 0; +} // =========================================================================== // Scripting API (web/ifcviewer.js wraps these into the IfcViewer object) @@ -808,6 +816,7 @@ int main(int /*argc*/, char** /*argv*/) { g_app->core.buildHizPipeline(); g_app->core.buildEdgePipeline(); g_app->core.buildPickPipeline(); + g_app->core.buildSelectionOutlinePipelines(); // Load the embedded sample sidecar (mounted into MEMFS via // --embed-file in CMakeLists.txt). The sample stays on the diff --git a/src/ifcviewer-web/tests/outline.spec.mjs b/src/ifcviewer-web/tests/outline.spec.mjs new file mode 100644 index 0000000000..b02fc3b7ed --- /dev/null +++ b/src/ifcviewer-web/tests/outline.spec.mjs @@ -0,0 +1,45 @@ +// This file was generated with the assistance of an AI coding tool. +// +// Selection silhouette outline. The renderer's blue selection tint says +// nothing about an object that is already blue, so the halo is the cue that +// has to survive that case: this paints the whole sample the exact palette +// blue that collides, selects one element, and checks the canvas changes. +import { test, expect } from '@playwright/test'; + +const shot = (page) => page.locator('#viewer-canvas').screenshot(); + +test('halo reads on an object painted the selection tint colour', async ({ page }) => { + const gpuErrors = []; + page.on('console', (msg) => { + if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(msg.text())) + gpuErrors.push(msg.text()); + }); + page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message)); + + await page.goto('/scripting.html'); + await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), + null, { timeout: 30_000 }); + await page.waitForTimeout(1500); + + const n = await page.evaluate(async () => { + const v = window.viewer; + const objs = await v.getObjects(); + v.setColor(objs, '#3987e5'); + v.setSelection([objs[0].objectId]); + return objs.length; + }); + expect(n).toBeGreaterThan(0); + await page.waitForTimeout(600); + const withOutline = await shot(page); + + await page.evaluate(() => window.viewer.setSelectionOutline(false)); + await page.waitForTimeout(600); + const withoutOutline = await shot(page); + + expect( + Buffer.compare(withOutline, withoutOutline), + 'the outline toggle changed nothing — halo never drew', + ).not.toBe(0); + + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +}); diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js index fb1d6c8cdc..11b37c677f 100644 --- a/src/ifcviewer-web/web/ifcviewer.js +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -413,6 +413,17 @@ hideSelected: function () { Module._hide_selected_c(); }, isolateSelected: function () { Module._isolate_selected_c(); }, + // The white-on-dark halo drawn around selected objects (on by default). + // The renderer also tints the selection blue, which says nothing when + // the object is already blue — hence a cue that does not depend on the + // object's colour. Turn it off to get the tint alone. + setSelectionOutline: function (on) { + Module._ifcv_set_selection_outline_c(on ? 1 : 0); + }, + selectionOutlineEnabled: function () { + return Module._ifcv_selection_outline_is_on_c() !== 0; + }, + // ---- Colour ---------------------------------------------------------- // Paint objects a flat colour, replacing whatever the model baked in. diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 6e54a414a3..c84d63f0d4 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -993,6 +993,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { return vec4(srgbToLinear(color), alpha_out); } +// Selection silhouette mask. Same vertex pulling as vs_main, but the only +// output is coverage: 1 where a selected object is drawn, nothing anywhere +// else. The pass shares the main depth buffer read-only, so the mask is the +// selection AS VISIBLE — an occluded object contributes nothing and gets no +// halo. encodeSelectionOutlinePass dilates this into the outline. +@fragment +fn fs_mask(in: VsOut) -> @location(0) f32 { + if (is_section_clipped(in.world_pos)) { discard; } + if (in.object_id >= arrayLength(&sel_flags)) { discard; } + if ((sel_flags[in.object_id] & 1u) == 0u) { discard; } + return 1.0; +} + // --------------------------- Pick pipeline --------------------------------- // Same vertex pulling as vs_main, but VsOutPick carries only the object_id // (flat-interpolated). Fragment writes the object_id to an R32UInt target. @@ -4826,6 +4839,475 @@ void ViewportCore::releaseEdgeResources() { if (edge_bgl_) { wgpuBindGroupLayoutRelease(edge_bgl_); edge_bgl_ = nullptr; } } +// =========================================================================== +// Selection silhouette outline: buildSelectionOutlinePipelines + +// ensureSelectionOutlineTextures + encodeSelectionMaskPass + +// encodeSelectionOutlinePass +// +// A halo drawn just OUTSIDE the selected objects, so the cue does not depend +// on the object's own colour the way the fs_main selection tint does — a blue +// element in a blue-tinted selection is otherwise indistinguishable. +// +// Three steps: a geometry pass writes a coverage mask (fs_mask), then two +// fullscreen passes dilate it. The dilation is separable — a horizontal max +// into an RGBA8 scratch, then a vertical max composited onto the surface — +// because the naive 2D disc is O(r^2) taps per pixel and a 3-physical-pixel +// radius on a HiDPI canvas is already 100+ loads over the whole screen. +// Separable makes it O(r), and the square structuring element it implies is +// invisible at this radius. +// =========================================================================== + +namespace { + +// Scratch format for the horizontal pass. r = max over the inner radius, +// g = max over the outer radius, b = this pixel's own coverage passed +// through so the vertical pass needs only this one texture bound. +constexpr WGPUTextureFormat kSelScratchFormat = WGPUTextureFormat_RGBA8Unorm; + +const char* SEL_OUTLINE_WGSL = R"( +struct OutlineUniforms { + inner_color: vec4, // rgb + alpha of the ring hugging the silhouette + outer_color: vec4, // rgb + alpha of the band beyond it + inner_radius: f32, // physical pixels + outer_radius: f32, // physical pixels, >= inner_radius + _pad0: f32, + _pad1: f32, +}; + +@group(0) @binding(0) var src: texture_2d; +@group(0) @binding(1) var u: OutlineUniforms; + +// Undo the swap chain's implicit linear->sRGB write encoding, exactly as +// the main shader does, so the halo's bytes are the colour we asked for. +fn srgbToLinear(s: vec3) -> vec3 { + let lo = s / 12.92; + let hi = pow((s + 0.055) / 1.055, vec3(2.4)); + return select(hi, lo, s <= vec3(0.04045)); +} + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + let x = f32((vid << 1u) & 2u) * 2.0 - 1.0; + let y = f32(vid & 2u) * 2.0 - 1.0; + return vec4(x, y, 0.0, 1.0); +} + +// Horizontal half of the dilation. Reads the resolved coverage mask. +@fragment +fn fs_dilate_h(@builtin(position) frag: vec4) -> @location(0) vec4 { + let p = vec2(i32(frag.x), i32(frag.y)); + let max_x = i32(textureDimensions(src).x) - 1; + let ri = i32(u.inner_radius); + let ro = i32(u.outer_radius); + + let here = textureLoad(src, p, 0).r; + var inner = 0.0; + var outer = 0.0; + for (var dx = -ro; dx <= ro; dx = dx + 1) { + let m = textureLoad(src, vec2(clamp(p.x + dx, 0, max_x), p.y), 0).r; + outer = max(outer, m); + if (dx >= -ri && dx <= ri) { inner = max(inner, m); } + } + return vec4(inner, outer, here, 1.0); +} + +// Vertical half, plus the composite. Reads the scratch written above. +@fragment +fn fs_outline(@builtin(position) frag: vec4) -> @location(0) vec4 { + let p = vec2(i32(frag.x), i32(frag.y)); + let max_y = i32(textureDimensions(src).y) - 1; + let ri = i32(u.inner_radius); + let ro = i32(u.outer_radius); + + // Coverage at this pixel. Inside the silhouette there is nothing to + // draw: the halo sits strictly outside, so a selected element's own + // colour is never painted over. + let here = textureLoad(src, p, 0).b; + if (here >= 0.999) { discard; } + + var inner = 0.0; + var outer = 0.0; + for (var dy = -ro; dy <= ro; dy = dy + 1) { + let s = textureLoad(src, vec2(p.x, clamp(p.y + dy, 0, max_y)), 0); + outer = max(outer, s.g); + if (dy >= -ri && dy <= ri) { inner = max(inner, s.r); } + } + + // Two concentric rings, written as differences so they never overlap: + // `in_ring` is the dilation minus the shape, `out_ring` is the wider + // dilation minus the narrower one. + let in_ring = clamp(inner - here, 0.0, 1.0); + let out_ring = clamp(outer - inner, 0.0, 1.0); + + let a_in = in_ring * u.inner_color.a; + let a_out = out_ring * u.outer_color.a * (1.0 - in_ring); + let a = a_in + a_out; + if (a <= 0.004) { discard; } + + // Straight (unpremultiplied) alpha out — the SrcAlpha blend factor + // does the premultiply, which is what the surface's premultiplied + // alpha mode expects to find in the buffer. + let rgb = (srgbToLinear(u.inner_color.rgb) * a_in + + srgbToLinear(u.outer_color.rgb) * a_out) / a; + return vec4(rgb, a); +} +)"; + +} // namespace + +bool ViewportCore::buildSelectionOutlinePipelines() { + // ---- Mask pass. Reuses the main shader module + pipeline layout, so it + // vertex-pulls identically and sees the same sel_flags binding. Depth is + // the main pass's, bound read-only: LessEqual against already-written + // scene depth keeps only the fragments that actually survived. + { + WGPUColorTargetState target = {}; + target.format = WGPUTextureFormat_R8Unorm; + target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = main_shader_module_; + frag.entryPoint = svFromCStr("fs_mask"); + frag.targetCount = 1; + frag.targets = ⌖ + + 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 = pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.sel_mask_pipeline"); + rp_desc.vertex.module = main_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = &depth; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + // No cull: an open shell (a wall face, a plate) would otherwise + // punch holes in its own silhouette when seen from behind. + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = kViewportSampleCount; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + sel_mask_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!sel_mask_pipeline_) { + Log::warn() << "wgpu selection mask pipeline creation failed"; + return false; + } + } + + // ---- Shared fullscreen resources. One BGL for both dilation passes: + // each binds a different source texture through the same shape. + WGPUBindGroupLayoutEntry entries[2] = {}; + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Fragment; + entries[0].texture.sampleType = WGPUTextureSampleType_Float; + entries[0].texture.viewDimension = WGPUTextureViewDimension_2D; + entries[1].binding = 1; + entries[1].visibility = WGPUShaderStage_Fragment; + entries[1].buffer.type = WGPUBufferBindingType_Uniform; + entries[1].buffer.minBindingSize = sizeof(SelOutlineUniforms); + + WGPUBindGroupLayoutDescriptor bgl_desc = {}; + bgl_desc.entryCount = 2; + bgl_desc.entries = entries; + bgl_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_bgl"); + sel_outline_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc); + + WGPUPipelineLayoutDescriptor pl_desc = {}; + pl_desc.bindGroupLayoutCount = 1; + pl_desc.bindGroupLayouts = &sel_outline_bgl_; + pl_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_pipeline_layout"); + sel_outline_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc); + + WGPUShaderSourceWGSL wgsl_src = {}; + wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl_src.code = svFromCStr(SEL_OUTLINE_WGSL); + WGPUShaderModuleDescriptor sm_desc = {}; + sm_desc.nextInChain = &wgsl_src.chain; + sm_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_wgsl"); + sel_outline_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc); + + WGPUBufferDescriptor ub = {}; + ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + ub.size = sizeof(SelOutlineUniforms); + ub.label = svFromCStr("ifcviewer-wgpu.sel_outline_uniforms"); + sel_outline_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub); + + // ---- Horizontal dilation into the scratch target. Opaque write. + { + WGPUColorTargetState target = {}; + target.format = kSelScratchFormat; + target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = sel_outline_shader_module_; + frag.entryPoint = svFromCStr("fs_dilate_h"); + frag.targetCount = 1; + frag.targets = ⌖ + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = sel_outline_pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.sel_dilate_h_pipeline"); + rp_desc.vertex.module = sel_outline_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = nullptr; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = 1; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + sel_dilate_h_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!sel_dilate_h_pipeline_) { + Log::warn() << "wgpu selection dilate pipeline creation failed"; + return false; + } + } + + // ---- Vertical dilation + composite onto the resolved surface. + { + 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 target = {}; + target.format = surface_view_format_; + target.blend = &blend; + target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = sel_outline_shader_module_; + frag.entryPoint = svFromCStr("fs_outline"); + frag.targetCount = 1; + frag.targets = ⌖ + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = sel_outline_pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_pipeline"); + rp_desc.vertex.module = sel_outline_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = nullptr; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = 1; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + sel_outline_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!sel_outline_pipeline_) { + Log::warn() << "wgpu selection outline pipeline creation failed"; + return false; + } + } + return true; +} + +void ViewportCore::ensureSelectionOutlineTextures(int w, int h) { + if (w == sel_mask_w_ && h == sel_mask_h_ && sel_mask_view_) return; + releaseSelectionOutlineTextures(); + + WGPUTextureDescriptor desc = {}; + desc.dimension = WGPUTextureDimension_2D; + desc.size.width = std::uint32_t(w); + desc.size.height = std::uint32_t(h); + desc.size.depthOrArrayLayers = 1; + desc.mipLevelCount = 1; + + // Multisampled coverage target, matching the main pass so it can share + // the depth attachment; resolved down to the single-sample mask the + // dilation reads. The resolve is what gives the halo the same edge + // antialiasing as the geometry it traces. + desc.usage = WGPUTextureUsage_RenderAttachment; + desc.format = WGPUTextureFormat_R8Unorm; + desc.sampleCount = kViewportSampleCount; + desc.label = svFromCStr("ifcviewer-wgpu.sel_mask_msaa"); + sel_mask_msaa_texture_ = wgpuDeviceCreateTexture(device_, &desc); + sel_mask_msaa_view_ = wgpuTextureCreateView(sel_mask_msaa_texture_, nullptr); + + desc.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_TextureBinding; + desc.sampleCount = 1; + desc.label = svFromCStr("ifcviewer-wgpu.sel_mask"); + sel_mask_texture_ = wgpuDeviceCreateTexture(device_, &desc); + sel_mask_view_ = wgpuTextureCreateView(sel_mask_texture_, nullptr); + + desc.format = kSelScratchFormat; + desc.label = svFromCStr("ifcviewer-wgpu.sel_scratch"); + sel_scratch_texture_ = wgpuDeviceCreateTexture(device_, &desc); + sel_scratch_view_ = wgpuTextureCreateView(sel_scratch_texture_, nullptr); + + sel_mask_w_ = w; + sel_mask_h_ = h; + + // The bind groups name the views we just replaced. + if (sel_dilate_bind_group_) { + wgpuBindGroupRelease(sel_dilate_bind_group_); + sel_dilate_bind_group_ = nullptr; + } + if (sel_outline_bind_group_) { + wgpuBindGroupRelease(sel_outline_bind_group_); + sel_outline_bind_group_ = nullptr; + } +} + +void ViewportCore::releaseSelectionOutlineTextures() { + if (sel_scratch_view_) { wgpuTextureViewRelease(sel_scratch_view_); sel_scratch_view_ = nullptr; } + if (sel_scratch_texture_) { wgpuTextureRelease(sel_scratch_texture_); sel_scratch_texture_ = nullptr; } + if (sel_mask_view_) { wgpuTextureViewRelease(sel_mask_view_); sel_mask_view_ = nullptr; } + if (sel_mask_texture_) { wgpuTextureRelease(sel_mask_texture_); sel_mask_texture_ = nullptr; } + if (sel_mask_msaa_view_) { wgpuTextureViewRelease(sel_mask_msaa_view_); sel_mask_msaa_view_ = nullptr; } + if (sel_mask_msaa_texture_) { wgpuTextureRelease(sel_mask_msaa_texture_); sel_mask_msaa_texture_ = nullptr; } + sel_mask_w_ = sel_mask_h_ = 0; +} + +bool ViewportCore::selectionOutlineActive() const { + return selection_outline_enabled_ && selection_.count() > 0 + && sel_mask_pipeline_ && sel_outline_pipeline_ && sel_mask_view_; +} + +void ViewportCore::encodeSelectionMaskPass(WGPUCommandEncoder enc) { + if (!selectionOutlineActive() || !depth_view_ || !frame_bind_group_) return; + + WGPURenderPassColorAttachment color = {}; + color.view = sel_mask_msaa_view_; + color.resolveTarget = sel_mask_view_; + color.loadOp = WGPULoadOp_Clear; + // Only the resolve is ever read, so the multisampled samples can go. + color.storeOp = WGPUStoreOp_Discard; + color.clearValue = {0.0, 0.0, 0.0, 0.0}; + color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + + // Read-only depth: the scene's own z, already written by the main pass. + // WebGPU requires the load/store ops be left undefined when a depth + // attachment is read-only, which the zero-init here does. + WGPURenderPassDepthStencilAttachment depth = {}; + depth.view = depth_view_; + depth.depthReadOnly = true; + depth.stencilReadOnly = true; + + WGPURenderPassDescriptor pass_desc = {}; + pass_desc.colorAttachmentCount = 1; + pass_desc.colorAttachments = &color; + pass_desc.depthStencilAttachment = &depth; + pass_desc.label = svFromCStr("ifcviewer-wgpu.sel_mask_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + wgpuRenderPassEncoderSetPipeline(pass, sel_mask_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr); + + // Same draw stream as the main pass, opaque and transparent together — + // a selected element that happens to be translucent still gets a halo. + for (const auto& [session_model_id, m] : models_gpu_) { + if (m.hidden) continue; + for (const auto& c : m.chunks) { + if (!c.bind_group || c.total_visible_vertices == 0) continue; + wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, c.total_visible_vertices, 1, 0, 0); + } + } + + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); +} + +void ViewportCore::encodeSelectionOutlinePass(WGPUCommandEncoder enc, + WGPUTextureView surface_view, + int dpr) { + if (!selectionOutlineActive() || !surface_view || !sel_dilate_h_pipeline_) return; + + // Ring widths in LOGICAL pixels, scaled here so the halo looks the same + // on a HiDPI canvas as it does on a 1x one. + const float scale = float(std::max(1, dpr)); + SelOutlineUniforms u = {}; + u.inner_color[0] = 1.0f; u.inner_color[1] = 1.0f; + u.inner_color[2] = 1.0f; u.inner_color[3] = 1.0f; + u.outer_color[0] = 0.04f; u.outer_color[1] = 0.04f; + u.outer_color[2] = 0.04f; u.outer_color[3] = 0.85f; + u.inner_radius = 2.0f * scale; + u.outer_radius = 3.0f * scale; + wgpuQueueWriteBuffer(queue_, sel_outline_uniform_buffer_, 0, &u, sizeof(u)); + + if (!sel_dilate_bind_group_) { + WGPUBindGroupEntry e[2] = {}; + e[0].binding = 0; + e[0].textureView = sel_mask_view_; + e[1].binding = 1; + e[1].buffer = sel_outline_uniform_buffer_; + e[1].size = sizeof(SelOutlineUniforms); + WGPUBindGroupDescriptor bg = {}; + bg.layout = sel_outline_bgl_; + bg.entryCount = 2; + bg.entries = e; + bg.label = svFromCStr("ifcviewer-wgpu.sel_dilate_bind_group"); + sel_dilate_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg); + } + if (!sel_outline_bind_group_) { + WGPUBindGroupEntry e[2] = {}; + e[0].binding = 0; + e[0].textureView = sel_scratch_view_; + e[1].binding = 1; + e[1].buffer = sel_outline_uniform_buffer_; + e[1].size = sizeof(SelOutlineUniforms); + WGPUBindGroupDescriptor bg = {}; + bg.layout = sel_outline_bgl_; + bg.entryCount = 2; + bg.entries = e; + bg.label = svFromCStr("ifcviewer-wgpu.sel_outline_bind_group"); + sel_outline_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg); + } + + { + WGPURenderPassColorAttachment color = {}; + color.view = sel_scratch_view_; + color.loadOp = WGPULoadOp_Clear; + 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.sel_dilate_h_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + wgpuRenderPassEncoderSetPipeline(pass, sel_dilate_h_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, sel_dilate_bind_group_, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); + } + + { + WGPURenderPassColorAttachment color = {}; + color.view = surface_view; + color.loadOp = WGPULoadOp_Load; + color.storeOp = WGPUStoreOp_Store; + color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + + WGPURenderPassDescriptor pass_desc = {}; + pass_desc.colorAttachmentCount = 1; + pass_desc.colorAttachments = &color; + pass_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + wgpuRenderPassEncoderSetPipeline(pass, sel_outline_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, sel_outline_bind_group_, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); + } +} + // =========================================================================== // Pick + raycast (#84-t) // =========================================================================== @@ -6413,6 +6895,7 @@ void ViewportCore::configureSurface(int width_px, int height_px) { ensureDepthTexture(width_px, height_px); ensureMsaaColorTexture(width_px, height_px); ensureHizTextures(width_px, height_px); + ensureSelectionOutlineTextures(width_px, height_px); // depth_view_ was just replaced; force the HiZ + edge bind groups // to rebuild against the new view on next encode. if (hiz_bind_group_) { @@ -6897,9 +7380,16 @@ void ViewportCore::render() { wgpuRenderPassEncoderEnd(pass); wgpuRenderPassEncoderRelease(pass); + // Selection coverage, while the main pass's depth is still current. The + // halo itself composites AFTER the edge pass, so the edge multiply does + // not darken it. + encodeSelectionMaskPass(enc); + // Edge silhouette + HiZ resolve, before the surface-targeted overlays. if (edges_enabled_) encodeEdgePass(enc, view); + encodeSelectionOutlinePass(enc, view, dpr_int); + int hiz_submitted_slot = -1; if (hiz_enabled_) hiz_submitted_slot = encodeHizResolve(enc); diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 72e39c4209..9e651f358f 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -703,6 +703,36 @@ public: // Called from shutdown() before the device dies. void releaseEdgeResources(); + // ---- Selection silhouette outline ------------------------------------- + // + // A halo drawn just outside the selected objects. The fs_main selection + // tint mixes toward blue, which says nothing on an object that is already + // blue; the halo is a fixed colour against the BACKGROUND instead, so it + // reads whatever the element is painted. + // + // Build the mask + dilation pipelines. Run after buildPipelines(), which + // owns the shader module and pipeline layout the mask pass reuses. + bool buildSelectionOutlinePipelines(); + + // (Re)allocate the coverage mask + dilation scratch to the surface size. + // Idempotent when dimensions match. + void ensureSelectionOutlineTextures(int w, int h); + void releaseSelectionOutlineTextures(); + + // Coverage pass: draws the selection into the mask, depth-tested against + // the main pass's z so only visible surface contributes. Must be encoded + // after the main pass and before encodeSelectionOutlinePass. + void encodeSelectionMaskPass(WGPUCommandEncoder enc); + + // Dilate the mask and composite the halo onto the resolved surface. + // `dpr` scales the ring widths so they hold their apparent size on a + // HiDPI canvas. No-op when nothing is selected. + void encodeSelectionOutlinePass(WGPUCommandEncoder enc, + WGPUTextureView surface_view, int dpr); + + void setSelectionOutlineEnabled(bool on) { selection_outline_enabled_ = on; } + bool selectionOutlineEnabled() const { return selection_outline_enabled_; } + // ---- Pick + raycast (#84-t) ------------------------------------------- // // Build the pick pipeline. Reuses the main shader module's @@ -1102,6 +1132,39 @@ private: WGPUBindGroup edge_bind_group_ = nullptr; bool edges_enabled_ = true; + // Selection silhouette outline. The mask is rendered multisampled so it + // can share the main depth attachment, then resolved; the scratch holds + // the horizontal half of the separable dilation. + struct SelOutlineUniforms { + float inner_color[4]; + float outer_color[4]; + float inner_radius; // physical px + float outer_radius; // physical px + float _pad0; + float _pad1; + }; + WGPURenderPipeline sel_mask_pipeline_ = nullptr; + WGPUTexture sel_mask_msaa_texture_ = nullptr; + WGPUTextureView sel_mask_msaa_view_ = nullptr; + WGPUTexture sel_mask_texture_ = nullptr; + WGPUTextureView sel_mask_view_ = nullptr; + WGPUTexture sel_scratch_texture_ = nullptr; + WGPUTextureView sel_scratch_view_ = nullptr; + int sel_mask_w_ = 0; + int sel_mask_h_ = 0; + WGPUShaderModule sel_outline_shader_module_ = nullptr; + WGPUBindGroupLayout sel_outline_bgl_ = nullptr; + WGPUPipelineLayout sel_outline_pipeline_layout_ = nullptr; + WGPUBuffer sel_outline_uniform_buffer_ = nullptr; + WGPURenderPipeline sel_dilate_h_pipeline_ = nullptr; + WGPUBindGroup sel_dilate_bind_group_ = nullptr; + WGPURenderPipeline sel_outline_pipeline_ = nullptr; + WGPUBindGroup sel_outline_bind_group_ = nullptr; + bool selection_outline_enabled_ = true; + + // True when there is something to outline and the resources are live. + bool selectionOutlineActive() const; + // Pick pass. Reuses pipeline_layout_ — same set of bindings as the // main pass since the pick fragment also vertex-pulls instance data. WGPURenderPipeline pick_pipeline_ = nullptr;