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) <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-03 18:07:52 +10:00
parent 83a7836081
commit c9f269a0e5
6 changed files with 619 additions and 1 deletions
+1 -1
View File
@@ -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
+9
View File
@@ -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
+45
View File
@@ -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([]);
});
+11
View File
@@ -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.