diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 13166c78e7..25defe9874 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -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). diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index a2d524cd2e..902ed873d8 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -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(); diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html index 2438149eac..c9bd23bdda 100644 --- a/src/ifcviewer-web/shell.html +++ b/src/ifcviewer-web/shell.html @@ -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); diff --git a/src/ifcviewer-web/tests/serve.mjs b/src/ifcviewer-web/tests/serve.mjs index cbbe84f538..3bb00217a9 100644 --- a/src/ifcviewer-web/tests/serve.mjs +++ b/src/ifcviewer-web/tests/serve.mjs @@ -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'); diff --git a/src/ifcviewer-web/tests/smoke.spec.mjs b/src/ifcviewer-web/tests/smoke.spec.mjs index c697557fab..864dfbc5f5 100644 --- a/src/ifcviewer-web/tests/smoke.spec.mjs +++ b/src/ifcviewer-web/tests/smoke.spec.mjs @@ -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 diff --git a/src/ifcviewer/ModelGpuData.h b/src/ifcviewer/ModelGpuData.h index 8cae832837..3aa141e574 100644 --- a/src/ifcviewer/ModelGpuData.h +++ b/src/ifcviewer/ModelGpuData.h @@ -277,11 +277,12 @@ struct ModelGpuData { std::string streaming_file_path; uint64_t streaming_vertex_section_offset = 0; uint64_t streaming_index_section_offset = 0; - // Web only: chunk byte ranges come from the JS-registered File via - // Blob.slice (async), not from a synchronous fopen on streaming_file_path. - // Set by loadSidecarFromBlobWeb so driveStreamingLoads routes this model - // through the async blob path instead of the MEMFS sync read. - bool streaming_from_blob = false; + // Web only: chunk byte ranges come from the JS-side source — a picked File + // (Blob.slice) or a remote URL (HTTP Range) — read asynchronously, not via + // a synchronous fopen on streaming_file_path. Set by loadSidecarMetadataWeb + // so driveStreamingLoads routes this model through the async web path + // instead of the MEMFS sync read. + bool streaming_from_web = false; // For each mesh in meshes[], the chunk it lives in plus the chunk-local // offsets into that chunk's vertex_storage and index_buffer. Populated diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 6a74ceae62..afda2302b0 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -2313,12 +2313,12 @@ void ViewportCore::driveStreamingLoads() { // queue requests with nothing to drain them. Chunks would // never go resident. #if defined(__EMSCRIPTEN__) - // Blob-sourced models read chunk bytes asynchronously via - // Blob.slice (the whole file is never in the heap). The chunk goes - // resident in the JS completion callback; hold is_loading until then - // so it isn't re-issued every frame. The embedded MEMFS sample falls - // through to the synchronous fopen path below. - if (cand.m->streaming_from_blob) { + // Web-sourced models (picked File or remote URL) read chunk bytes + // asynchronously (Blob.slice / HTTP Range) — the whole file is never + // in the heap. The chunk goes resident in the JS completion callback; + // hold is_loading until then so it isn't re-issued every frame. The + // embedded MEMFS sample falls through to the synchronous fopen path. + if (cand.m->streaming_from_web) { c.is_loading = true; c.last_visible_frame_idx = streaming_frame_idx_; beginWebChunkLoad(cand.mid, cand.ci); @@ -3141,25 +3141,71 @@ std::uint32_t ViewportCore::loadSidecarFromPath(const std::string& path) { namespace { -// Size of the JS-registered File in bytes, or 0 if none. Bounds the metadata -// tail read (index-section end .. EOF). +// Active byte-source size in bytes, or 0 if none. Bounds the metadata tail +// read (index-section end .. EOF). The source is either a picked File +// (Module.__ifcvFile, local) or a remote URL whose total length was resolved +// up front (Module.__ifcvUrlSize). The File takes precedence if both are set. EM_JS(double, ifcvFileSize, (void), { - return (Module["__ifcvFile"] && Module["__ifcvFile"].size) - ? Module["__ifcvFile"].size : 0; + if (Module["__ifcvFile"] && Module["__ifcvFile"].size) return Module["__ifcvFile"].size; + if (Module["__ifcvUrl"]) return Module["__ifcvUrlSize"] || 0; + return 0; }); -// Read [offset, offset+size) of the registered File into dst (which must hold -// `size` bytes), then call back _ifcv_on_range_done(reqId, ok). Async — the -// Blob is sliced and its ArrayBuffer copied into the wasm heap on resolve. +// Read [offset, offset+size) of the active source into dst (which must hold +// `size` bytes), then call back _ifcv_on_range_done(reqId, ok). Async. Local: +// Blob.slice. Remote: an HTTP Range request — if a server ignores Range and +// returns the whole body (200), slice out the requested window so it still +// works (just without the bandwidth saving). EM_JS(void, ifcvReadRangeInto, (int reqId, double offset, double size, void* dst), { + var deliver = function(ok, buf) { + if (ok && buf) HEAPU8.set(new Uint8Array(buf), dst); + Module["_ifcv_on_range_done"](reqId, ok ? 1 : 0); + }; var f = Module["__ifcvFile"]; - if (!f) { Module["_ifcv_on_range_done"](reqId, 0); return; } - f.slice(offset, offset + size).arrayBuffer().then(function(buf) { - HEAPU8.set(new Uint8Array(buf), dst); - Module["_ifcv_on_range_done"](reqId, 1); - }).catch(function(e) { - Module["_ifcv_on_range_done"](reqId, 0); - }); + if (f) { + f.slice(offset, offset + size).arrayBuffer() + .then(function(buf) { deliver(1, buf); }) + .catch(function(e) { deliver(0, null); }); + return; + } + var url = Module["__ifcvUrl"]; + if (url) { + var end = offset + size - 1; + fetch(url, { headers: { "Range": "bytes=" + offset + "-" + end } }) + .then(function(resp) { + if (resp.status !== 206 && resp.status !== 200) { deliver(0, null); return; } + var full = resp.status === 200; + return resp.arrayBuffer().then(function(buf) { + if (full && buf.byteLength > size) buf = buf.slice(offset, offset + size); + deliver(1, buf); + }); + }) + .catch(function(e) { deliver(0, null); }); + return; + } + Module["_ifcv_on_range_done"](reqId, 0); +}); + +// Switch the active source to a remote URL and resolve its total byte length +// (needed to bound the metadata tail read), then call _ifcv_source_ready(ok). +// Tries a HEAD's Content-Length first, then a 0-0 ranged GET's Content-Range +// total. Clears any local File so the URL source is used. +EM_JS(void, ifcvBeginUrlSource, (const char* urlPtr), { + var url = UTF8ToString(urlPtr); + Module["__ifcvFile"] = null; + Module["__ifcvUrl"] = url; + Module["__ifcvUrlSize"] = 0; + var ready = function(ok) { Module["_ifcv_source_ready"](ok ? 1 : 0); }; + fetch(url, { method: "HEAD" }).then(function(resp) { + var len = resp.ok ? parseInt(resp.headers.get("Content-Length") || "0", 10) : 0; + if (len > 0) { Module["__ifcvUrlSize"] = len; ready(1); return; } + return fetch(url, { headers: { "Range": "bytes=0-0" } }).then(function(r2) { + var cr = r2.headers.get("Content-Range"); // "bytes 0-0/12345" + var total = cr ? parseInt(cr.split("/")[1] || "0", 10) : 0; + Module["__ifcvUrlSize"] = total; + ready(total > 0); + }); + }).catch(function(e) { ready(0); }); }); // One in-flight multi-range read: a sequence of coalesced plans, each read @@ -3176,6 +3222,11 @@ struct WebRangeRead { std::unordered_map g_web_reads; int g_web_read_next = 1; +// The ViewportCore awaiting an async URL-source size resolution. Set by +// loadSidecarFromUrlWeb; consumed by the ifcv_source_ready callback. One +// ViewportCore exists on web, so a single slot suffices. +ViewportCore* g_url_ready_core = nullptr; + // Issue the current plan's Blob.slice, or finish (success) if all plans done. void webIssueCurrentPlan(int id) { auto it = g_web_reads.find(id); @@ -3244,6 +3295,21 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_on_range_done(int reqId, int ok) { webIssueCurrentPlan(reqId); } +// JS callback once a remote URL source's total size has been resolved. Runs +// the shared metadata bootstrap against the now-active URL source. Exported as +// _ifcv_source_ready (see ifcviewer-web/CMakeLists.txt). +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_source_ready(int ok) { + ViewportCore* core = g_url_ready_core; + g_url_ready_core = nullptr; + if (!core) return; + if (!ok) { + Log::warn() << "ifcviewer-web: could not resolve remote sidecar size " + "(server must support HEAD or Range)"; + return; + } + core->loadSidecarMetadataWeb("net:model.ifcview"); +} + void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx) { auto it = models_gpu_.find(model_id); if (it == models_gpu_.end()) return; @@ -3293,36 +3359,40 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i }); } -void ViewportCore::loadSidecarFromBlobWeb() { +// Source-agnostic metadata bootstrap. The active byte-source (local File or +// remote URL) is already set on the JS side, so this reads via ifcvFileSize + +// webReadRangesAsync without caring which it is: head (16 B) → num_vertex_bytes; +// the 4-byte index count after the vertex section; then the metadata tail +// (index-section end .. EOF). The bulk vertex/index sections are never read +// here — they stream per chunk through beginWebChunkLoad. `source_label` is a +// log/identity tag stored as file_path (chunk reads go through the JS source, +// not this path). +void ViewportCore::loadSidecarMetadataWeb(std::string source_label) { if (!device_ || !queue_) { - Log::warn() << "loadSidecarFromBlobWeb: wgpu not initialised"; + Log::warn() << "loadSidecarMetadataWeb: wgpu not initialised"; return; } const double fsize = ifcvFileSize(); if (fsize <= 0.0) { - Log::warn() << "loadSidecarFromBlobWeb: no File registered"; + Log::warn() << "loadSidecarMetadataWeb: no active source / zero size"; return; } - // Head (16 B) → num_vertex_bytes; then the 4-byte index count after the - // vertex section; then the metadata tail (index-section end .. EOF). Each - // hop is a tiny Blob.slice; the bulk vertex/index sections are never read - // here — they stream per chunk through beginWebChunkLoad. webReadRangesAsync(0, {{0, SIDECAR_HEAD_BYTES}}, - [this, fsize](bool ok, std::vector&& head) { + [this, fsize, source_label](bool ok, std::vector&& head) { std::uint32_t nvb = 0; if (!ok || !parseSidecarHead(head.data(), head.size(), nvb)) { - Log::warn() << "loadSidecarFromBlobWeb: bad sidecar head"; + Log::warn() << "loadSidecarMetadataWeb: bad sidecar head"; return; } const std::uint64_t vsec = SIDECAR_HEAD_BYTES; const std::uint64_t idx_count_off = std::uint64_t(SIDECAR_HEAD_BYTES) + nvb; webReadRangesAsync(0, {{idx_count_off, 4}}, - [this, fsize, nvb, vsec, idx_count_off] + [this, fsize, nvb, vsec, idx_count_off, source_label] (bool ok2, std::vector&& cnt) { if (!ok2 || cnt.size() < 4) { - Log::warn() << "loadSidecarFromBlobWeb: short index count"; + Log::warn() << "loadSidecarMetadataWeb: short index count"; return; } std::uint32_t num_indices = 0; @@ -3330,26 +3400,26 @@ void ViewportCore::loadSidecarFromBlobWeb() { const std::uint64_t isec = idx_count_off + 4; const std::uint64_t tail_off = isec + std::uint64_t(num_indices) * 4u; if (double(tail_off) > fsize) { - Log::warn() << "loadSidecarFromBlobWeb: tail offset past EOF"; + Log::warn() << "loadSidecarMetadataWeb: tail offset past EOF"; return; } const std::uint64_t tail_len = std::uint64_t(fsize) - tail_off; webReadRangesAsync(0, {{tail_off, tail_len}}, - [this, vsec, nvb, isec, num_indices] + [this, vsec, nvb, isec, num_indices, source_label] (bool ok3, std::vector&& tail) { if (!ok3) { - Log::warn() << "loadSidecarFromBlobWeb: tail read failed"; + Log::warn() << "loadSidecarMetadataWeb: tail read failed"; return; } StreamingSidecar sc; - sc.file_path = "blob:model.ifcview"; + sc.file_path = source_label; sc.vertex_section_offset = vsec; sc.vertex_total_bytes = nvb; sc.index_section_offset = isec; sc.index_total_count = num_indices; if (!parseSidecarTail(tail.data(), tail.size(), sc.meta)) { - Log::warn() << "loadSidecarFromBlobWeb: bad metadata tail"; + Log::warn() << "loadSidecarMetadataWeb: bad metadata tail"; return; } const std::size_t n_meshes = sc.meta.meshes.size(); @@ -3358,16 +3428,33 @@ void ViewportCore::loadSidecarFromBlobWeb() { applyCachedModel(mid, std::move(sc)); auto mit = models_gpu_.find(mid); if (mit != models_gpu_.end()) - mit->second.streaming_from_blob = true; + mit->second.streaming_from_web = true; viewAll(); host_->requestFrame(); - Log::info() << "ifcviewer-web: loaded blob sidecar (id " - << mid << ", " << n_meshes << " meshes, " + Log::info() << "ifcviewer-web: loaded sidecar (" << source_label + << ", id " << mid << ", " << n_meshes << " meshes, " << n_instances << " instances)"; }); }); }); } + +void ViewportCore::loadSidecarFromBlobWeb() { + // The picked File is already on Module.__ifcvFile (set by shell.html); the + // metadata bootstrap reads it via Blob.slice. + loadSidecarMetadataWeb("blob:model.ifcview"); +} + +void ViewportCore::loadSidecarFromUrlWeb(std::string url) { + if (!device_ || !queue_) { + Log::warn() << "loadSidecarFromUrlWeb: wgpu not initialised"; + return; + } + // Resolve the URL's total size first (async); ifcv_source_ready then runs + // the shared bootstrap once Module.__ifcvUrl/__ifcvUrlSize are populated. + g_url_ready_core = this; + ifcvBeginUrlSource(url.c_str()); +} #endif // __EMSCRIPTEN__ void ViewportCore::finalizeModel(std::uint32_t model_id) { diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 40c11e8767..f8d8d1df57 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -357,19 +357,24 @@ public: std::uint32_t loadSidecarFromPath(const std::string& path); #if defined(__EMSCRIPTEN__) - // Web byte-range load (#88). Loads a sidecar from the JS-registered - // File (Module.__ifcvFile) WITHOUT copying the whole file into the - // wasm heap: the head + tail metadata are read via Blob.slice, the - // streaming model is built, and it is tagged blob-sourced so each - // chunk's vertex/index byte ranges are pulled lazily through the async - // path. Asynchronous — returns immediately and frames the model from - // the JS completion callback. resetScene() first to replace. + // Web byte-range load (#88). Streams a sidecar from a JS-side source + // WITHOUT copying the whole file into the wasm heap: head + tail metadata + // are read via byte ranges, the streaming model is built, and it is tagged + // web-sourced so each chunk's vertex/index ranges are pulled lazily. + // Asynchronous — returns immediately and frames the model from the JS + // completion callback. resetScene() first to replace. + // + // - Blob: the picked File on Module.__ifcvFile (Blob.slice). + // - URL: a remote sidecar (HTTP Range); resolves total size first, then + // runs the shared bootstrap via the ifcv_source_ready callback. void loadSidecarFromBlobWeb(); + void loadSidecarFromUrlWeb(std::string url); + void loadSidecarMetadataWeb(std::string source_label); - // Kick off the async blob read of one chunk's vertex + index byte - // ranges. applyStreamedChunk runs in the JS completion callback; - // c.is_loading is held until then. No-op if the model/chunk vanished - // mid-flight (e.g. a resetScene landed between issue and completion). + // Kick off the async read of one chunk's vertex + index byte ranges (from + // the active web source). applyStreamedChunk runs in the JS completion + // callback; c.is_loading is held until then. No-op if the model/chunk + // vanished mid-flight (e.g. a resetScene landed between issue and done). void beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx); #endif