mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 11:20:21 +00:00
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:
@@ -98,11 +98,14 @@ target_link_options(IfcViewerWeb PRIVATE
|
||||
"-sEXIT_RUNTIME=0"
|
||||
# Expose the C entry points to JS. _raf_tick_c drives the RAF loop
|
||||
# (shell.html); _load_sidecar_from_blob_c loads a user-picked File via
|
||||
# byte-range Blob.slice reads; _ifcv_on_range_done is the completion
|
||||
# callback the JS range reader invokes when a slice has landed in the
|
||||
# heap. EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but
|
||||
# doesn't add them to Module.
|
||||
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_blob_c','_ifcv_on_range_done']"
|
||||
# byte-range Blob.slice reads; _load_sidecar_from_url_c streams a remote
|
||||
# sidecar via HTTP Range; _ifcv_on_range_done / _ifcv_source_ready are the
|
||||
# JS→C completion callbacks for a landed range / a resolved URL size.
|
||||
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
|
||||
# add them to Module. ccall lets shell.html pass a JS string (the ?model
|
||||
# URL) to load_sidecar_from_url_c without manual heap marshalling.
|
||||
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_blob_c','_load_sidecar_from_url_c','_ifcv_on_range_done','_ifcv_source_ready']"
|
||||
"-sEXPORTED_RUNTIME_METHODS=['ccall']"
|
||||
# Streaming + chunked geometry want a heap that can grow as buffers
|
||||
# arrive. 256 MB initial, 2 GB ceiling (matches the wasm32 pointer
|
||||
# cap; --shared64 / MEMORY64 would lift this later if we need it).
|
||||
|
||||
@@ -205,6 +205,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_blob_c() {
|
||||
g_app->core.loadSidecarFromBlobWeb();
|
||||
}
|
||||
|
||||
// Called from shell.html (e.g. a ?model=URL query param) to stream a sidecar
|
||||
// hosted at `url` via HTTP Range requests — the same per-chunk byte-range path
|
||||
// as the local File load, but the bytes come off the network instead of a
|
||||
// Blob. Asynchronous; the model frames itself once metadata lands. Exported
|
||||
// to JS via EXPORTED_FUNCTIONS in CMakeLists.txt.
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_url_c(const char* url) {
|
||||
if (!g_app || !g_app->ready || !url) return;
|
||||
g_app->core.resetScene();
|
||||
g_app->core.loadSidecarFromUrlWeb(url);
|
||||
}
|
||||
|
||||
int main(int /*argc*/, char** /*argv*/) {
|
||||
Log::info() << "ifcviewer-web: starting";
|
||||
g_app = new AppState();
|
||||
|
||||
@@ -71,11 +71,20 @@
|
||||
// chain — which is the configuration that stalls device-callback
|
||||
// delivery (verified during web bring-up).
|
||||
var collapsedOnce = false;
|
||||
var urlLoadTried = false;
|
||||
var modelUrl = new URLSearchParams(location.search).get('model');
|
||||
function shellTick() {
|
||||
if (Module._app_ptr && Module._raf_tick_c) {
|
||||
// First time the app goes live, collapse the log overlay so it
|
||||
// stops covering the viewport.
|
||||
if (!collapsedOnce) { statusEl.classList.add('ready'); collapsedOnce = true; }
|
||||
// ?model=URL streams a remote sidecar via HTTP Range once the app
|
||||
// is live (one-shot). Same-origin needs no CORS; cross-origin URLs
|
||||
// require the host to send CORS + Accept-Ranges headers.
|
||||
if (!urlLoadTried && modelUrl && Module.ccall) {
|
||||
urlLoadTried = true;
|
||||
Module.ccall('load_sidecar_from_url_c', null, ['string'], [modelUrl]);
|
||||
}
|
||||
Module._raf_tick_c(Module._app_ptr);
|
||||
}
|
||||
requestAnimationFrame(shellTick);
|
||||
|
||||
@@ -14,6 +14,10 @@ const ROOT = process.env.WEB_BUILD_DIR
|
||||
? path.resolve(process.env.WEB_BUILD_DIR)
|
||||
: path.resolve(__dirname, '../../../build-web');
|
||||
const PORT = Number(process.env.PORT || 8124);
|
||||
// Fallback root: the ifcviewer-web source dir holds sample.ifcview (which is
|
||||
// embedded in the wasm, not copied into build-web). Lets the remote-backend
|
||||
// test fetch http://localhost/sample.ifcview over real HTTP Range.
|
||||
const SRC = path.resolve(__dirname, '..');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -28,11 +32,48 @@ http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
let p = decodeURIComponent(url.pathname);
|
||||
if (p === '/') p = '/IfcViewerWeb.html';
|
||||
const file = path.join(ROOT, p);
|
||||
// Contain to ROOT.
|
||||
if (!file.startsWith(ROOT)) { res.writeHead(403).end(); return; }
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
|
||||
const inRoot = path.join(ROOT, p);
|
||||
const inSrc = path.join(SRC, p);
|
||||
// Contain to one of the two allowed roots.
|
||||
if (!inRoot.startsWith(ROOT) && !inSrc.startsWith(SRC)) { res.writeHead(403).end(); return; }
|
||||
let body;
|
||||
try { body = await readFile(inRoot); }
|
||||
catch { body = await readFile(inSrc); } // fall back to the source dir
|
||||
const ctype = MIME[path.extname(p)] || 'application/octet-stream';
|
||||
|
||||
// HEAD: headers only — lets the remote backend resolve total size.
|
||||
if (req.method === 'HEAD') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': ctype,
|
||||
'Content-Length': body.length,
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Range: serve 206 partial content so the HTTP-Range backend is exercised
|
||||
// exactly as a real Accept-Ranges host would (handles bytes=a-b and a-).
|
||||
const range = req.headers['range'];
|
||||
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
||||
if (m) {
|
||||
let start = m[1] === '' ? undefined : parseInt(m[1], 10);
|
||||
let end = m[2] === '' ? undefined : parseInt(m[2], 10);
|
||||
if (start === undefined) { start = body.length - end; end = body.length - 1; } // bytes=-N
|
||||
if (end === undefined || end > body.length - 1) end = body.length - 1; // bytes=a-
|
||||
if (Number.isNaN(start) || start > end) { res.writeHead(416).end(); return; }
|
||||
const slice = body.subarray(start, end + 1);
|
||||
res.writeHead(206, {
|
||||
'Content-Type': ctype,
|
||||
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': slice.length,
|
||||
});
|
||||
res.end(slice);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': ctype, 'Accept-Ranges': 'bytes' });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end('not found');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user