ifcviewer: x-ray marquee selects through occluders

Box select resolved hits by reading the depth-tested object_id MRT, so only
the front-most surface in each pixel could ever come back. In x-ray that is
wrong twice over: you can see the geometry behind, and you still cannot
select it.

Add a second box-pick path used only while x-ray is active. It runs the same
vs_pick geometry through fs_boxpick with depth compare Always, no depth write
and no colour targets, scissored to the marquee — so nothing culls a fragment
behind another and the pass's only output is an atomicOr of one bit per
object into a hit bitmask. Reading that back gives every object with geometry
inside the box, occluded or not.

The bitmask rides alongside sel_flags at group(0) binding 2, allocated and
bound by ensureSelectionFlagsBuffer so the two can never disagree about how
many object ids exist. The layout entry is FRAGMENT-visible only: WebGPU
forbids a read_write storage buffer in the vertex stage, and every pipeline
shares this layout. Back-face culling is off for the pass — a box landing
inside a closed solid would otherwise see none of its faces and miss it.

Outside x-ray the depth-tested read stands, so a plain marquee still takes
only what is visible. A failure to build the pipeline falls back to that path
rather than breaking box select.

Tests cover the three properties worth having: x-ray selects strictly more,
its result is a superset of the plain one (a bare count would wave through a
wrong scissor or an off-by-one in the bit decode), and turning x-ray off
restores front-most-only. They need a model with real self-occlusion, which
sidecar_bake cannot currently produce — it segfaults on any input, including
the pristine sample.ifc — so they skip with an explanation until a fixture is
supplied. See the note at the top of the spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-28 15:48:16 +10:00
parent 8cc96127ce
commit a4f2075c3d
3 changed files with 444 additions and 4 deletions
@@ -0,0 +1,151 @@
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// X-ray marquee must select THROUGH occluders (#xray-boxpick).
//
// Outside x-ray a box select reads the depth-tested object_id MRT, so only the
// front-most surface in each pixel can be returned. In x-ray the viewer runs a
// depth-less pass scissored to the marquee instead, ORing one bit per object
// into a bitmask — so anything with geometry inside the box is selected whether
// or not something sits in front of it.
//
// The embedded sample is three well-separated elements with barely any mutual
// occlusion, which cannot tell the two paths apart. These need a model whose
// elements genuinely stack behind each other, served as occluders.ifcview.
//
// That fixture is NOT committed. Authoring one needs sidecar_bake, which
// currently segfaults on any input including the pristine sample.ifc — so the
// suite skips rather than failing for a reason that has nothing to do with the
// feature. To run it, drop any multi-storey .ifcview into the serve directory
// as occluders.ifcview; once sidecar_bake works again this should become a
// purpose-built fixture (three stacked plates is enough) generated by
// make_sample.py and committed alongside sample.ifcview.
const MODEL = '/IfcViewerWeb.html?model=/occluders.ifcview';
const SERVE_DIR = process.env.WEB_BUILD_DIR
? path.resolve(process.env.WEB_BUILD_DIR)
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../build-web');
const FIXTURE = path.join(SERVE_DIR, 'occluders.ifcview');
test.beforeAll(() => {
test.skip(!fs.existsSync(FIXTURE),
`no ${FIXTURE} — see the note at the top of this file for how to supply one`);
});
async function loadModel(page) {
const loaded = page.waitForEvent('console', {
predicate: (m) => /loaded sidecar \(source/.test(m.text()),
timeout: 30_000,
});
await page.goto(MODEL);
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await loaded;
await page.waitForTimeout(1200); // stream chunks + settle
}
// Drag the select button (Web preset: RMB) across the given canvas rect.
async function marquee(page, box, fx0, fy0, fx1, fy1) {
const px = (fx, fy) => [box.x + box.width * fx, box.y + box.height * fy];
const [x0, y0] = px(fx0, fy0);
const [x1, y1] = px(fx1, fy1);
await page.mouse.move(x0, y0);
await page.mouse.down({ button: 'right' });
await page.mouse.move((x0 + x1) / 2, (y0 + y1) / 2, { steps: 4 });
await page.mouse.move(x1, y1, { steps: 6 });
await page.mouse.up({ button: 'right' });
await page.waitForTimeout(700); // async box-pick map + apply
// ifcv_get_selection_c returns the TOTAL, so (0, 0) is a pure count.
return page.evaluate(() => window.Module._ifcv_get_selection_c(0, 0));
}
const setXray = (page, on) => page.evaluate((want) => {
if ((window.Module._xray_is_active_c() !== 0) !== want) window.Module._toggle_xray_c();
return window.Module._xray_is_active_c();
}, on);
test('x-ray marquee selects through occluders; plain marquee does not', async ({ page }) => {
const gpuErrors = [];
page.on('console', (m) => {
if (/Uncaptured WebGPU error|is invalid|Validation error/i.test(m.text()))
gpuErrors.push(m.text());
});
page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message));
await loadModel(page);
const box = await page.locator('#viewer-canvas').boundingBox();
// Same rect both times — the ONLY difference is x-ray.
const R = [0.3, 0.3, 0.7, 0.7];
expect(await setXray(page, false)).toBe(0);
const visibleOnly = await marquee(page, box, ...R);
await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0));
expect(await setXray(page, true)).toBe(1);
const throughAll = await marquee(page, box, ...R);
expect(visibleOnly, 'plain marquee selected nothing — the rect missed the model')
.toBeGreaterThan(0);
expect(throughAll,
`x-ray marquee (${throughAll}) did not select more than the depth-tested one ` +
`(${visibleOnly}) — it is still taking only front-most surfaces`)
.toBeGreaterThan(visibleOnly);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('x-ray marquee result is a superset of the plain one', async ({ page }) => {
// Every element a depth-tested marquee finds is visible, so selecting through
// must still include it. Catches a box-pick that returns a *different* set
// rather than a bigger one (wrong scissor, stale bits, off-by-one in the id
// decode) — all of which a bare count comparison would wave through.
await loadModel(page);
const box = await page.locator('#viewer-canvas').boundingBox();
const R = [0.35, 0.35, 0.65, 0.65];
const ids = () => page.evaluate(() => {
const n = window.Module._ifcv_get_selection_c(0, 0);
if (!n) return [];
const ptr = window.Module._malloc(n * 4);
try {
window.Module._ifcv_get_selection_c(ptr, n);
return Array.from(window.Module.HEAPU32.subarray(ptr >>> 2, (ptr >>> 2) + n));
} finally { window.Module._free(ptr); }
});
await setXray(page, false);
await marquee(page, box, ...R);
const plain = await ids();
await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0));
await setXray(page, true);
await marquee(page, box, ...R);
const xray = new Set(await ids());
expect(plain.length).toBeGreaterThan(0);
const missing = plain.filter((id) => !xray.has(id));
expect(missing, `x-ray marquee dropped ids the plain one found: ${missing.slice(0, 8)}`)
.toEqual([]);
});
test('leaving x-ray restores front-most-only box select', async ({ page }) => {
// The depth-tested path must not be left behind by the x-ray branch.
await loadModel(page);
const box = await page.locator('#viewer-canvas').boundingBox();
const R = [0.3, 0.3, 0.7, 0.7];
await setXray(page, true);
const through = await marquee(page, box, ...R);
await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0));
expect(await setXray(page, false)).toBe(0);
const back = await marquee(page, box, ...R);
expect(back).toBeGreaterThan(0);
expect(back, 'x-ray off still selected through — the branch is sticky')
.toBeLessThan(through);
});