mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 10:57:49 +00:00
ifcviewer-web: contribution-cull streaming + combined loaded/needed/total bar
Streaming picked candidates on raw frustum visibility, so viewAll over a big
federation (all models in-frustum) fetched every chunk — even fine chunks that
project to sub-pixel and the renderer never draws. Gate streaming on the SAME
contribution decision the render path already makes.
- New per-chunk contribution_visible_count: instances that passed frustum AND
the contribution cull (projected radius >= min_radius_px), counted BEFORE HiZ
— so it's stable while the camera is still (unlike the HiZ-post counters,
which flip frame-to-frame and would thrash the loader) and shifts only on
navigation, when the working set should. The candidate gate skips chunks with
count 0: the network pulls only what's resolvable now; the rest stream in as
you approach. Verified: fit view needs the geometry, zoomed-out needs 0.
- Loading UI reworked from per-model segments to a combined bar over the whole
federation: dark track = not needed for this view, dim = needed-but-unloaded,
bright = loaded. "loaded / needed" = how done THIS view is; "needed / total" =
how much of the model the view requires — "Loading 45 / 90 MB for this view ·
12% of 718 MB total". Driven by ViewportCore::streamingByteProgress + ifcv_bytes_*.
Two fixes from testing: (1) show a distinct overhead phase while total==0
("Loading model data — X MB · Y/N models ready") so the metadata download
isn't a dead-looking bar; (2) re-assert display:block every active frame so the
bar REAPPEARS when navigation reveals new chunks (was only set in
beginLoadProgress → stayed hidden after the first catch-up). Per-model exports
kept for a future detailed view.
111/111 unit + 6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -63,7 +65,10 @@
|
||||
<div id="progress"><div id="progress-fill"></div></div>
|
||||
<div id="progress-panel">
|
||||
<div id="progress-summary"></div>
|
||||
<div id="progress-segs"></div>
|
||||
<div id="progress-track">
|
||||
<div id="progress-needed"></div>
|
||||
<div id="progress-loaded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="add-btn" title="Add file(s) to the current scene (federation)">Add</button>
|
||||
<button id="open-btn">Open .ifcview…</button>
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user