diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 6fce649499..f4443f1ad4 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_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c']" + "-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_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 2ad8d7337c..f48203e461 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -285,6 +285,25 @@ extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_total_c(int idx) { int r = 0, t = 0; g_app->core.streamingModelProgress(idx, r, t); return t; } +// Combined byte progress for the loading bar: total geometry, bytes the current +// view needs (contribution-culled), and how much of that is loaded. Doubles so +// JS gets exact byte counts well past 2 GB. +extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_total_c() { + if (!g_app) return 0.0; + std::uint64_t tot = 0, need = 0, load = 0; + g_app->core.streamingByteProgress(tot, need, load); return double(tot); +} +extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_needed_c() { + if (!g_app) return 0.0; + std::uint64_t tot = 0, need = 0, load = 0; + g_app->core.streamingByteProgress(tot, need, load); return double(need); +} +extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_loaded_c() { + if (!g_app) return 0.0; + std::uint64_t tot = 0, need = 0, load = 0; + g_app->core.streamingByteProgress(tot, need, load); return double(load); +} + 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 3347ec4035..68ed076ee7 100644 --- a/src/ifcviewer-web/shell.html +++ b/src/ifcviewer-web/shell.html @@ -48,14 +48,16 @@ background: rgba(20,22,28,.9); padding: 8px 12px; border-radius: 6px; pointer-events: none; display: none; min-width: 280px; max-width: 60vw; } #progress-summary { margin-bottom: 6px; white-space: nowrap; } - /* One flex segment per model — fills blue as it streams, green when done, - grey while its metadata is still pending. Visualises parallel loading. */ - #progress-segs { display: flex; gap: 2px; } - #progress-segs > div { flex: 1 1 0; height: 7px; border-radius: 2px; - background: #2d3748; overflow: hidden; } - #progress-segs > div > i { display: block; height: 100%; width: 0%; - background: #3182ce; transition: width .15s ease; } - #progress-segs > div.done > i { background: #38a169; } + /* Combined bar over the FULL model content: dark track = not needed for this + view, dim = needed-but-not-loaded, bright = loaded. So the bright fill vs + the dim span shows "loaded / needed", and the dim span vs the whole track + shows "needed / total". */ + #progress-track { position: relative; height: 8px; border-radius: 3px; + background: #232833; overflow: hidden; } + #progress-needed, #progress-loaded { position: absolute; left: 0; top: 0; + height: 100%; width: 0%; transition: width .2s ease; } + #progress-needed { background: #2b4a6b; } + #progress-loaded { background: #3182ce; } @@ -63,7 +65,10 @@
-
+
+
+
+
@@ -198,59 +203,77 @@ var fillEl = document.getElementById('progress-fill'); var panelEl = document.getElementById('progress-panel'); var summaryEl = document.getElementById('progress-summary'); - var segsEl = document.getElementById('progress-segs'); + var neededEl = document.getElementById('progress-needed'); + var loadedEl = document.getElementById('progress-loaded'); var loadActive = false; var expectedModels = 1; + var caughtUpAt = 0; // Call with the number of models this batch will load (federation). window.beginLoadProgress = function(nModels) { loadActive = true; expectedModels = Math.max(1, nModels || 1); Module.__ifcvBytesLoaded = 0; - fillEl.style.width = '0%'; - segsEl.innerHTML = ''; - progEl.style.display = 'block'; - panelEl.style.display = 'block'; - summaryEl.textContent = 'Loading ' + expectedModels + + caughtUpAt = 0; + progEl.style.display = 'block'; + panelEl.style.display = 'block'; + summaryEl.textContent = 'Loading ' + expectedModels + ' model' + (expectedModels === 1 ? '' : 's') + '…'; }; - function endLoadProgress() { progEl.style.display = 'none'; panelEl.style.display = 'none'; } + function endLoadProgress() { + progEl.style.display = 'none'; panelEl.style.display = 'none'; loadActive = false; + } + function fmtMB(b) { return (b / 1e6).toFixed(b < 1e8 ? 1 : 0); } + // Combined progress over the whole federation, honouring contribution culling: + // loaded / needed = how done THIS view is + // needed / total = how much of the whole model this view even requires + // Auto-shows whenever there's work (initial load OR navigation revealing new + // chunks) and fades shortly after the current view is fully loaded. window.updateLoadProgress = function() { - if (!loadActive) return; + if (!Module._ifcv_bytes_total_c) return; + var total = Module._ifcv_bytes_total_c(); + var needed = Module._ifcv_bytes_needed_c(); + var loaded = Module._ifcv_bytes_loaded_c(); var mc = Module._ifcv_model_count_c ? Module._ifcv_model_count_c() : 0; - var N = Math.max(expectedModels, mc); - var mb = ((Module.__ifcvBytesLoaded || 0) / 1e6).toFixed(1); - // One segment per model; add/remove to match N. - while (segsEl.children.length < N) { - var d = document.createElement('div'); d.appendChild(document.createElement('i')); - segsEl.appendChild(d); - } - while (segsEl.children.length > N) segsEl.removeChild(segsEl.lastChild); - var aggR = 0, aggT = 0, done = 0; - for (var i = 0; i < N; i++) { - var seg = segsEl.children[i]; - if (i < mc) { // model has metadata → show its progress - var r = Module._ifcv_model_resident_c(i); - var t = Module._ifcv_model_total_c(i); - aggR += r; aggT += t; - var isDone = t > 0 && r >= t; - if (isDone) done++; - seg.className = isDone ? 'done' : ''; - seg.firstChild.style.width = - (isDone ? 100 : (t > 0 ? Math.round(100 * r / t) : 4)) + '%'; // sliver = metadata only - } else { // metadata not fetched yet → waiting - seg.className = ''; - seg.firstChild.style.width = '0%'; - } - } - fillEl.style.width = (aggT > 0 ? Math.round(100 * aggR / aggT) : 4) + '%'; - if (N > 0 && done >= N) { // every model fully resident → done - summaryEl.textContent = 'Loaded ' + N + ' model' + (N === 1 ? '' : 's') + ' · ' + mb + ' MB'; - loadActive = false; - setTimeout(endLoadProgress, 1200); + var dlMB = (Module.__ifcvBytesLoaded || 0) / 1e6; + // No geometry chunks yet = still fetching the per-model "overhead" (mesh + + // instance metadata) needed before we can even tell which chunks are visible. + var overhead = total === 0; + var streaming = needed > loaded + 1; // geometry still to fetch for this view + if (overhead || streaming) { loadActive = true; caughtUpAt = 0; } + if (!loadActive) return; + // Re-assert visibility every active frame so the bar REAPPEARS when + // navigation reveals new chunks after a previous hide (fix: was only set + // in beginLoadProgress, so it stayed hidden). + progEl.style.display = 'block'; + panelEl.style.display = 'block'; + + if (overhead) { + var frac = expectedModels > 0 ? mc / expectedModels : 0; + neededEl.style.width = '100%'; + loadedEl.style.width = (100 * frac) + '%'; + fillEl.style.width = Math.max(4, 100 * frac) + '%'; + summaryEl.textContent = 'Loading model data — ' + dlMB.toFixed(1) + ' MB · ' + + mc + ' / ' + expectedModels + ' models ready'; return; } - summaryEl.textContent = 'Loading ' + N + ' model' + (N === 1 ? '' : 's') + ' — ' + - done + ' done · ' + (mc - done) + ' streaming · ' + (N - mc) + ' waiting · ' + mb + ' MB'; + + // Geometry phase: bar spans the whole model; dim = needed for this view, + // bright = loaded. "loaded / needed" = this view's progress; "needed / total" + // = how much of the model this view requires. + neededEl.style.width = (100 * needed / total) + '%'; + loadedEl.style.width = (100 * loaded / total) + '%'; + fillEl.style.width = (needed > 0 ? Math.round(100 * loaded / needed) : 100) + '%'; + var pctNeeded = Math.round(100 * needed / total); + var more = (mc < expectedModels) ? ' · ' + mc + '/' + expectedModels + ' models' : ''; + if (streaming) { + summaryEl.textContent = 'Loading ' + fmtMB(loaded) + ' / ' + fmtMB(needed) + + ' MB for this view · ' + pctNeeded + '% of ' + fmtMB(total) + ' MB total' + more; + } else { + summaryEl.textContent = (pctNeeded >= 99 ? 'Loaded ' : 'View loaded — ') + + fmtMB(loaded) + ' MB · ' + pctNeeded + '% of ' + fmtMB(total) + ' MB total' + more; + if (!caughtUpAt) caughtUpAt = performance.now(); + if (performance.now() - caughtUpAt > 1500) endLoadProgress(); + } }; // File-browse loading (#88, byte-range). The picked File object is stashed diff --git a/src/ifcviewer/ModelGpuData.h b/src/ifcviewer/ModelGpuData.h index e7fcb25737..17ecb8a894 100644 --- a/src/ifcviewer/ModelGpuData.h +++ b/src/ifcviewer/ModelGpuData.h @@ -123,6 +123,13 @@ struct ModelGpuData { uint32_t total_visible_vertices = 0; uint32_t total_visible_draws = 0; uint32_t frustum_visible_count = 0; + // Instances that passed frustum AND the contribution cull (projected + // radius ≥ min_radius_px), but BEFORE HiZ. Streaming gates on this so + // it only fetches chunks big enough on screen to actually draw — + // without coupling to HiZ occlusion (which flips frame-to-frame and + // would thrash the loader). Stable while the camera is still; changes + // only on navigation, which is exactly when the working set should. + uint32_t contribution_visible_count = 0; // Opaque-first partition counts. The cull loop fills // visible_draws_scratch with all opaque visible instances first, diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 51b0425a2e..000bb9cab8 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -2280,7 +2280,12 @@ void ViewportCore::driveStreamingLoads() { auto& c = m.chunks[ci]; if (c.is_resident) continue; if (c.is_loading) continue; - if (c.frustum_visible_count == 0) continue; + // Contribution cull: only fetch chunks big enough on screen to + // actually draw at the current view — not everything in the + // frustum. Zoomed out over a big federation this skips the fine + // chunks that project to sub-pixel, so the network only pulls + // what's resolvable now; the rest stream in as you approach. + if (c.contribution_visible_count == 0) continue; if (c.blocked_cooldown_until_frame_idx > streaming_frame_idx_) continue; candidates.push_back({&m, ci, mid, candidate_priority(c)}); } @@ -2589,6 +2594,7 @@ std::uint32_t ViewportCore::cullModelCpuCompute( c.opaque_visible_vertices = 0; c.opaque_visible_draws = 0; c.frustum_visible_count = 0; + c.contribution_visible_count = 0; c.current_priority = 0.0f; } @@ -2653,6 +2659,10 @@ std::uint32_t ViewportCore::cullModelCpuCompute( // first cuts the HiZ-tested population by ~5× on real scenes. if (contrib_enabled && projected_px < min_radius_px) return; + // Passed frustum + contribution (pre-HiZ). Streaming uses this to + // decide what's worth fetching for the current view. + ++c.contribution_visible_count; + if (hiz_active && hiz_occluded(inst.world_aabb_min, inst.world_aabb_max)) { ++hiz_rejects; @@ -3656,6 +3666,29 @@ void ViewportCore::streamingModelProgress(int idx, int& resident_chunks, } } +void ViewportCore::streamingByteProgress(std::uint64_t& total_bytes, + std::uint64_t& needed_bytes, + std::uint64_t& loaded_bytes) const { + // total = all geometry across every model (the whole federation). + // needed = chunks the CURRENT view wants (passed contribution culling). + // loaded = the needed chunks that are resident. + // So loaded/needed = how done this view is; needed/total = how much of the + // whole model this view even requires. + total_bytes = needed_bytes = loaded_bytes = 0; + for (const auto& [mid, m] : models_gpu_) { + if (m.hidden) continue; + for (const auto& c : m.chunks) { + const std::uint64_t bytes = + c.vertex_byte_size + c.index_count * sizeof(std::uint32_t); + total_bytes += bytes; + if (c.contribution_visible_count > 0) { + needed_bytes += bytes; + if (c.is_resident) loaded_bytes += bytes; + } + } + } +} + 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 b7985887e1..3b8d40407c 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -416,6 +416,14 @@ public: void streamingModelProgress(int idx, int& resident_chunks, int& total_chunks) const; + // Byte-level streaming progress for a combined loading bar. total = all + // geometry bytes; needed = bytes the current view wants (contribution- + // culled working set); loaded = the resident subset of needed. Lets the UI + // show "loaded / needed" for this view and "needed / total" as context. + void streamingByteProgress(std::uint64_t& total_bytes, + std::uint64_t& needed_bytes, + std::uint64_t& loaded_bytes) 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