From 13ec2be80791fc4f2b85fb129d6a645122b95522 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 1 Jul 2026 08:54:42 +1000 Subject: [PATCH] =?UTF-8?q?ifcviewer-web:=20streaming=20loading=20bar=20(m?= =?UTF-8?q?etadata=20=E2=86=92=20geometry=20progress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users had no feedback during the seconds before/while a model streams. Add a top progress strip + caption driven by the streaming state: - "Loading model… N MB" while the critical metadata downloads (no chunks yet), - "Loading geometry — R / T chunks · N MB" as chunks go resident, - "Loaded — T chunks · N MB", then it hides. ViewportCore::streamingProgress(resident, total) sums chunk residency across models; main_web exports ifcv_chunks_resident_c / ifcv_chunks_total_c for shell.html to poll each RAF. The EM_JS range reader accumulates Module.__ifcvBytesLoaded so the caption can show MB downloaded. Shown only for streamed loads (?model= URL / picked file), not the embedded sample. 6/6 web smoke pass. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer-web/CMakeLists.txt | 2 +- src/ifcviewer-web/main_web.cpp | 12 ++++++++ src/ifcviewer-web/shell.html | 53 ++++++++++++++++++++++++++++++++ src/ifcviewer/ViewportCore.cpp | 16 +++++++++- src/ifcviewer/ViewportCore.h | 5 +++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index b79a908a0b..b48348c387 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -104,7 +104,7 @@ target_link_options(IfcViewerWeb PRIVATE # 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_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_blob_c','_load_sidecar_from_url_c','_ifcv_on_range_done','_ifcv_source_ready','_ifcv_chunks_resident_c','_ifcv_chunks_total_c']" # ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large # sidecar streams by range instead of loading whole). Standard, zero-cost. diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 902ed873d8..1d463dd38c 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -216,6 +216,18 @@ extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_url_c(const char* url) { g_app->core.loadSidecarFromUrlWeb(url); } +// Streaming progress for the loading bar (shell.html polls these each frame). +// total == 0 while still fetching metadata; resident climbs to total as +// geometry chunks arrive. +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_resident_c() { + if (!g_app) return 0; + int r = 0, t = 0; g_app->core.streamingProgress(r, t); return r; +} +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_total_c() { + if (!g_app) return 0; + int r = 0, t = 0; g_app->core.streamingProgress(r, t); return t; +} + 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 c9bd23bdda..8254d22b15 100644 --- a/src/ifcviewer-web/shell.html +++ b/src/ifcviewer-web/shell.html @@ -26,10 +26,22 @@ border-radius: 4px; font-size: 12px; cursor: pointer; } #open-btn:hover { background: #3182ce; } #file-input { display: none; } + /* Streaming loading bar: a thin top progress strip + a centred caption. + Shown only while a network/file model streams; hidden once resident. */ + #progress { position: fixed; top: 0; left: 0; right: 0; height: 3px; + background: rgba(43,108,176,.2); z-index: 20; display: none; } + #progress-fill { height: 100%; width: 0%; background: #3182ce; + transition: width .15s ease; } + #progress-text { position: fixed; top: 10px; left: 50%; + transform: translateX(-50%); z-index: 20; font-size: 12px; + background: rgba(20,22,28,.85); padding: 4px 12px; border-radius: 4px; + pointer-events: none; display: none; } +
+
Starting…
@@ -83,8 +95,10 @@ // require the host to send CORS + Accept-Ranges headers. if (!urlLoadTried && modelUrl && Module.ccall) { urlLoadTried = true; + window.beginLoadProgress(); Module.ccall('load_sidecar_from_url_c', null, ['string'], [modelUrl]); } + window.updateLoadProgress(); Module._raf_tick_c(Module._app_ptr); } requestAnimationFrame(shellTick); @@ -97,6 +111,44 @@ statusEl.classList.add('error'); } + // --- Streaming loading bar ------------------------------------------------ + // Driven by the C-side progress exports (resident/total chunks) + the + // bytes-downloaded counter the EM_JS range reader maintains. Shown only for + // streamed loads (URL / picked file), not the tiny embedded sample. + var progEl = document.getElementById('progress'); + var fillEl = document.getElementById('progress-fill'); + var textEl = document.getElementById('progress-text'); + var loadActive = false; + window.beginLoadProgress = function() { + loadActive = true; + Module.__ifcvBytesLoaded = 0; + fillEl.style.width = '0%'; + progEl.style.display = 'block'; + textEl.style.display = 'block'; + textEl.textContent = 'Loading model…'; + }; + function endLoadProgress() { progEl.style.display = 'none'; textEl.style.display = 'none'; } + window.updateLoadProgress = function() { + if (!loadActive) return; + var R = Module._ifcv_chunks_resident_c ? Module._ifcv_chunks_resident_c() : 0; + var T = Module._ifcv_chunks_total_c ? Module._ifcv_chunks_total_c() : 0; + var mb = ((Module.__ifcvBytesLoaded || 0) / 1e6).toFixed(1); + if (T === 0) { // still fetching critical metadata + fillEl.style.width = '6%'; + textEl.textContent = 'Loading model… ' + mb + ' MB'; + return; + } + var pct = Math.round(100 * R / T); + fillEl.style.width = pct + '%'; + if (R >= T) { // all chunks resident → done + textEl.textContent = 'Loaded — ' + T + ' chunks · ' + mb + ' MB'; + loadActive = false; + setTimeout(endLoadProgress, 900); + return; + } + textEl.textContent = 'Loading geometry — ' + R + ' / ' + T + ' chunks · ' + mb + ' MB'; + }; + // File-browse loading (#88, byte-range). The picked File object is stashed // on Module.__ifcvFile and load_sidecar_from_blob_c reads it lazily via // Blob.slice — the file is NOT copied into the wasm heap, so a 500 MB @@ -116,6 +168,7 @@ } try { Module.__ifcvFile = f; // kept alive for lazy Blob.slice reads + window.beginLoadProgress(); Module._load_sidecar_from_blob_c(); } catch (e) { statusEl.textContent += 'load failed: ' + e + '\n'; diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 0c61be0e7b..8552ec6ccf 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -3219,7 +3219,10 @@ EM_JS(double, ifcvFileSize, (void), { // 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); + if (ok && buf) { + HEAPU8.set(new Uint8Array(buf), dst); + Module["__ifcvBytesLoaded"] = (Module["__ifcvBytesLoaded"] || 0) + buf.byteLength; + } Module["_ifcv_on_range_done"](reqId, ok ? 1 : 0); }; var f = Module["__ifcvFile"]; @@ -3608,6 +3611,17 @@ void ViewportCore::loadDeferredMetadataWeb(std::uint32_t model_id, } #endif // __EMSCRIPTEN__ +void ViewportCore::streamingProgress(int& resident_chunks, int& total_chunks) const { + resident_chunks = 0; + total_chunks = 0; + for (const auto& [mid, m] : models_gpu_) { + for (const auto& c : m.chunks) { + ++total_chunks; + if (c.is_resident) ++resident_chunks; + } + } +} + void ViewportCore::finalizeModel(std::uint32_t model_id) { auto it = pending_direct_loads_.find(model_id); if (it == pending_direct_loads_.end()) { diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 0207fc6d54..646f3e66ff 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -386,6 +386,11 @@ public: void beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx); #endif + // Streaming progress for a loading UI: resident vs total streaming chunks + // across all models. total == 0 means no streaming model is set up yet + // (still in the metadata phase). Cheap; safe to poll every frame. + void streamingProgress(int& resident_chunks, int& total_chunks) const; + // Direct-load (bonsai-side) entry points. Bonsai's SceneLoader feeds // the viewer one mesh + one instance at a time, then calls // finalizeModel once everything's staged. The staging map lives on