ifcviewer-web: stream remote sidecars over HTTP Range (?model=URL)

Adds a network byte-source alongside the local Blob one. The async-chunk
infra is source-agnostic — only the two JS primitives knew it was a Blob —
so this generalises them and reuses everything else:

  - ifcvReadRangeInto: local → Blob.slice; remote → fetch() with a Range
    header (206). If a server ignores Range and returns 200, the requested
    window is sliced out so it still works (without the bandwidth saving).
  - ifcvFileSize: Blob size, or the URL's total length resolved up front.
  - ifcvBeginUrlSource: resolves total size (HEAD Content-Length, else a
    0-0 ranged GET's Content-Range) then fires _ifcv_source_ready.
  - The metadata bootstrap is extracted into a source-agnostic
    loadSidecarMetadataWeb(label); loadSidecarFromBlobWeb / FromUrlWeb are
    thin entries. streaming_from_blob → streaming_from_web (now covers both).

main_web exports load_sidecar_from_url_c(url); shell.html reads a
?model=URL query param and ccalls it once the app is live (same-origin
needs no CORS; cross-origin hosts must send CORS + Accept-Ranges).

Test: serve.mjs now answers HEAD + Range (206) and falls back to the
ifcviewer-web source dir for sample.ifcview (embedded in the wasm, not in
build-web). New smoke case loads ?model=/sample.ifcview and asserts it
renders via the Range path. 6/6 web smoke + 107/107 unit pass; desktop
unaffected (web-guarded; only the shared field rename touches it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-30 12:44:37 +10:00
parent bc31e91e35
commit 23dc5dac48
8 changed files with 260 additions and 66 deletions
+38 -1
View File
@@ -167,7 +167,7 @@ test('loads a user-picked sidecar through the Blob.slice byte-range path', async
// 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()),
predicate: (m) => /loaded sidecar \(blob:/.test(m.text()),
timeout: 15_000,
});
await page.locator('#file-input').setInputFiles(samplePath);
@@ -194,6 +194,43 @@ test('loads a user-picked sidecar through the Blob.slice byte-range path', async
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('streams a remote sidecar over HTTP Range (?model= URL backend)', async ({ page }) => {
// The remote backend: ?model=URL resolves total size (HEAD), then reads the
// metadata + per-chunk byte ranges via HTTP Range (206) — same async-chunk
// path as the local Blob load, different byte source. serve.mjs answers
// Range requests, so this exercises it end-to-end (same-origin, no CORS).
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));
// Wait for the C side to confirm the URL-sourced load landed.
const loaded = page.waitForEvent('console', {
predicate: (m) => /loaded sidecar \(net:/.test(m.text()),
timeout: 20_000,
});
await page.goto('/IfcViewerWeb.html?model=/sample.ifcview');
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await loaded;
await page.waitForTimeout(800); // stream the chunk + a few frames
// The remote-streamed model must render: centre patch (cube) != corner.
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),
'remote-streamed model did not render — HTTP Range path 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