mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-09 21:53:40 +00:00
3cc759d72b
On first web load the sample stayed blank until a click/drag, then popped in. Root cause: the main draw + cull run before driveStreamingLoads in render(), so a chunk that becomes resident there is only painted a frame later. On desktop the streaming thread keeps inFlightApprox() > 0 during a load, so the render loop keeps ticking and the next frame paints it. On web the sync MEMFS / Blob load finishes instantly (inFlightApprox stays 0), so the single post-load requestFrame fired once and the on-demand loop went idle before the geometry was ever drawn — until some input re-armed it. Fix: arm a bounded settle burst (kStreamingSettleFrames) whenever there's streaming activity — a load this frame, work still queued, or a visible chunk not yet resident — and bleed it down over the next few frames, each requesting one more. Covers the cull→display latency under an on-demand loop and still quiesces at idle (no busy-rendering). General, not web-only. Regression test: the sample must render with NO pointer input — a centred patch (the framed cube) differs from a corner patch (background); a blank stall leaves both as background. Verified empirically with a no-interaction probe (canvas went from a static blank hash to a stable rendered one). 107/107 unit tests pass; 4/4 web smoke tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
8.0 KiB
JavaScript
195 lines
8.0 KiB
JavaScript
import { test, expect } from '@playwright/test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// End-to-end smoke test for the WebGPU build. Every bug from the bring-up
|
|
// of camera input + file loading was exactly this shape — the page would
|
|
// load but render blank, spam uncaptured WebGPU errors, or swallow mouse
|
|
// input behind an overlay. This test would have caught all of them.
|
|
|
|
// Capture the composited canvas pixels as a PNG buffer. We use
|
|
// Playwright's element screenshot rather than an in-page drawImage/
|
|
// toDataURL: reading a WebGPU canvas back through a 2D context returns
|
|
// blank (the swap-chain texture isn't persisted for 2D copy), whereas
|
|
// the browser's own capture path includes GPU layers.
|
|
async function shot(page) {
|
|
return page.locator('#viewer-canvas').screenshot();
|
|
}
|
|
|
|
test('renders the sample and orbits without WebGPU errors', async ({ page }) => {
|
|
const gpuErrors = [];
|
|
page.on('console', (msg) => {
|
|
const t = msg.text();
|
|
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) {
|
|
gpuErrors.push(t);
|
|
}
|
|
});
|
|
page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message));
|
|
|
|
await page.goto('/IfcViewerWeb.html');
|
|
|
|
// Init is complete once C publishes the app pointer (set in the wgpu
|
|
// device callback). If WebGPU is missing or the device promise stalls,
|
|
// this times out — a real failure, not a flake.
|
|
await page.waitForFunction(
|
|
() => !!(window.Module && window.Module._app_ptr),
|
|
null,
|
|
{ timeout: 30_000 },
|
|
);
|
|
|
|
// Let a few frames render the embedded sample.
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Drag across the canvas centre to orbit. The composited canvas must
|
|
// change — a single assertion that simultaneously proves the scene
|
|
// rendered (a blank canvas dragged stays blank), input is wired, and
|
|
// the log overlay isn't intercepting mouse events over the viewport.
|
|
const box = await page.locator('#viewer-canvas').boundingBox();
|
|
const cx = box.x + box.width / 2;
|
|
const cy = box.y + box.height / 2;
|
|
const before = await shot(page);
|
|
await page.mouse.move(cx, cy);
|
|
await page.mouse.down();
|
|
await page.mouse.move(cx + 140, cy + 50, { steps: 10 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(500);
|
|
const after = await shot(page);
|
|
expect(
|
|
Buffer.compare(before, after),
|
|
'orbit drag did not change the canvas — blank render or dead input',
|
|
).not.toBe(0);
|
|
|
|
// No WebGPU validation/OOM noise at any point.
|
|
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
|
|
});
|
|
|
|
test('sample renders without any interaction (streaming settle loop)', async ({ page }) => {
|
|
// Regression for the blank-until-click stall: the main draw + cull precede
|
|
// driveStreamingLoads, so a freshly-resident chunk paints a frame later. On
|
|
// web's on-demand loop a single post-load requestFrame wasn't enough, so the
|
|
// sample stayed blank until some input re-armed the loop. The settle burst
|
|
// keeps frames coming until streaming converges. Here: NO mouse input at all
|
|
// — a centred patch (the framed cube) must differ from a corner patch
|
|
// (background). If the loop stalls blank, both patches are background.
|
|
const gpuErrors = [];
|
|
page.on('console', (msg) => {
|
|
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(msg.text()))
|
|
gpuErrors.push(msg.text());
|
|
});
|
|
await page.goto('/IfcViewerWeb.html');
|
|
await page.waitForFunction(
|
|
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
|
|
|
|
// Settle window — strictly no pointer events.
|
|
await page.waitForTimeout(1500);
|
|
|
|
const box = await page.locator('#viewer-canvas').boundingBox();
|
|
const patch = (cx, cy) => page.screenshot({
|
|
clip: { x: Math.round(cx - 12), y: Math.round(cy - 12), width: 24, height: 24 },
|
|
});
|
|
const center = await patch(box.x + box.width / 2, box.y + box.height / 2);
|
|
const corner = await patch(box.x + 16, box.y + 16);
|
|
expect(
|
|
Buffer.compare(center, corner),
|
|
'centre patch matches corner — sample never rendered without interaction',
|
|
).not.toBe(0);
|
|
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
|
|
});
|
|
|
|
test('loads a user-picked sidecar through the Blob.slice byte-range path', async ({ page }) => {
|
|
// Exercises #88: the picked File is read via Blob.slice (metadata head/tail
|
|
// + per-chunk byte ranges) WITHOUT copying the whole file into the wasm
|
|
// heap. Distinct code path from the embedded MEMFS sample above, so it
|
|
// needs its own coverage — a broken blob read renders blank.
|
|
const gpuErrors = [];
|
|
page.on('console', (msg) => {
|
|
const t = msg.text();
|
|
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) {
|
|
gpuErrors.push(t);
|
|
}
|
|
});
|
|
page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message));
|
|
|
|
await page.goto('/IfcViewerWeb.html');
|
|
await page.waitForFunction(
|
|
() => !!(window.Module && window.Module._app_ptr),
|
|
null,
|
|
{ timeout: 30_000 },
|
|
);
|
|
|
|
// Pick the sample sidecar through the hidden file input. setInputFiles
|
|
// hands the page a real File, so the browser's Blob.slice reads it exactly
|
|
// as it would a user's 200-500 MB sidecar — just smaller. Wait for the C
|
|
// side to confirm the blob load landed (logged to stderr → console).
|
|
const samplePath = resolve(__dirname, '..', 'sample.ifcview');
|
|
const loaded = page.waitForEvent('console', {
|
|
predicate: (m) => /loaded blob sidecar/.test(m.text()),
|
|
timeout: 15_000,
|
|
});
|
|
await page.locator('#file-input').setInputFiles(samplePath);
|
|
await loaded;
|
|
await page.waitForTimeout(800); // let the chunk stream + a few frames draw
|
|
|
|
// Orbit: the composited canvas must change, proving the blob-streamed
|
|
// geometry rendered and input still drives it.
|
|
const box = await page.locator('#viewer-canvas').boundingBox();
|
|
const cx = box.x + box.width / 2;
|
|
const cy = box.y + box.height / 2;
|
|
const before = await shot(page);
|
|
await page.mouse.move(cx, cy);
|
|
await page.mouse.down();
|
|
await page.mouse.move(cx + 140, cy + 50, { steps: 10 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(500);
|
|
const after = await shot(page);
|
|
expect(
|
|
Buffer.compare(before, after),
|
|
'orbit after blob load did not change the canvas — blob read or stream failed',
|
|
).not.toBe(0);
|
|
|
|
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
|
|
});
|
|
|
|
test('click selects an object and the highlight renders (async pick)', async ({ page }) => {
|
|
// Exercises the async object-pick readback: a click maps the pick staging
|
|
// buffer via a spontaneous callback (no blocking spin, which would hang the
|
|
// page), routes object_id through selection, and the next render flushes the
|
|
// highlight. A broken async pick either hangs init/never selects (canvas
|
|
// unchanged) or spams WebGPU errors.
|
|
const gpuErrors = [];
|
|
page.on('console', (msg) => {
|
|
const t = msg.text();
|
|
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) {
|
|
gpuErrors.push(t);
|
|
}
|
|
});
|
|
page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message));
|
|
|
|
await page.goto('/IfcViewerWeb.html');
|
|
await page.waitForFunction(
|
|
() => !!(window.Module && window.Module._app_ptr),
|
|
null,
|
|
{ timeout: 30_000 },
|
|
);
|
|
await page.waitForTimeout(1000); // sample framed + a few frames drawn
|
|
|
|
// Click dead-centre, where the framed sample geometry sits. A plain
|
|
// down+up at one point is a pick (no drag), so it must change the canvas
|
|
// (selection highlight). If centre happens to miss, the assertion guards it.
|
|
const box = await page.locator('#viewer-canvas').boundingBox();
|
|
const cx = box.x + box.width / 2;
|
|
const cy = box.y + box.height / 2;
|
|
const before = await shot(page);
|
|
await page.mouse.click(cx, cy);
|
|
await page.waitForTimeout(600); // async pick result + flush + render
|
|
const after = await shot(page);
|
|
expect(
|
|
Buffer.compare(before, after),
|
|
'click did not change the canvas — async pick failed or hit empty space',
|
|
).not.toBe(0);
|
|
|
|
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
|
|
});
|