mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-09 21:53:40 +00:00
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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user