mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
ifcviewer-web: load many models from the URL + per-model loading panel
Query string: auto-load a whole federation, not one model. Accepts repeated
params (?model=a&model=b&…) and/or a comma list (?models=a,b,c); each becomes
its own streamed byte-source, clearing the embedded sample once then streaming
concurrently into one scene. A failed URL is reported without aborting the rest.
Loading UI: the bare aggregate chunk count didn't reveal the federation state,
so surface per-model progress. ViewportCore::streamingModelCount() +
streamingModelProgress(idx,…) (ordered by model_id = load order) feed
main_web's ifcv_model_count_c / ifcv_model_{resident,total}_c. shell.html draws
a panel: "Loading N models — X done · Y streaming · Z waiting · N MB" plus one
segment per model (blue fill while streaming, green when resident, grey while
its metadata is still pending) — so parallel loading and how many remain are
visible at a glance.
Verified: 10 models from a 10x ?model= query stream in parallel; the panel
steps 10 waiting → streaming → all done. 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']"
|
||||
"-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']"
|
||||
# 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.
|
||||
|
||||
@@ -222,6 +222,20 @@ extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_total_c() {
|
||||
int r = 0, t = 0; g_app->core.streamingProgress(r, t); return t;
|
||||
}
|
||||
|
||||
// Per-model progress for the federation loading panel: how many models are in
|
||||
// the scene, and the idx-th model's resident/total chunks (idx ordered by load).
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_count_c() {
|
||||
return g_app ? g_app->core.streamingModelCount() : 0;
|
||||
}
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_resident_c(int idx) {
|
||||
if (!g_app) return 0;
|
||||
int r = 0, t = 0; g_app->core.streamingModelProgress(idx, r, t); return r;
|
||||
}
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_total_c(int idx) {
|
||||
if (!g_app) return 0;
|
||||
int r = 0, t = 0; g_app->core.streamingModelProgress(idx, r, t); return t;
|
||||
}
|
||||
|
||||
int main(int /*argc*/, char** /*argv*/) {
|
||||
Log::info() << "ifcviewer-web: starting";
|
||||
g_app = new AppState();
|
||||
|
||||
@@ -29,22 +29,34 @@
|
||||
#open-btn:hover { background: #3182ce; }
|
||||
#add-btn:hover { background: #3b465c; }
|
||||
#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. */
|
||||
/* Streaming loading UI: a thin top progress strip (aggregate) + a centred
|
||||
panel with a per-model segmented bar. Shown only while models stream. */
|
||||
#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%;
|
||||
#progress-panel { 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; }
|
||||
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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="viewer-canvas" width="1280" height="800"></canvas>
|
||||
<div id="progress"><div id="progress-fill"></div></div>
|
||||
<div id="progress-text"></div>
|
||||
<div id="progress-panel">
|
||||
<div id="progress-summary"></div>
|
||||
<div id="progress-segs"></div>
|
||||
</div>
|
||||
<button id="add-btn" title="Add file(s) to the current scene (federation)">Add</button>
|
||||
<button id="open-btn">Open .ifcview…</button>
|
||||
<input id="file-input" type="file" accept=".ifcview" multiple>
|
||||
@@ -88,24 +100,34 @@
|
||||
// delivery (verified during web bring-up).
|
||||
var collapsedOnce = false;
|
||||
var urlLoadTried = false;
|
||||
var modelUrl = new URLSearchParams(location.search).get('model');
|
||||
// Models to auto-load from the query string, as a federation. Accepts
|
||||
// either repeated params (?model=a&model=b&…) or a comma list
|
||||
// (?models=a,b,c) — or a mix. Each becomes its own streamed source.
|
||||
var qs = new URLSearchParams(location.search);
|
||||
var modelUrls = qs.getAll('model');
|
||||
var modelsCsv = qs.get('models');
|
||||
if (modelsCsv) modelUrls = modelUrls.concat(
|
||||
modelsCsv.split(',').map(function(s){ return s.trim(); }).filter(Boolean));
|
||||
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._load_sidecar_from_source_c) {
|
||||
// Auto-load every ?model= 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. They stream concurrently
|
||||
// into one scene (chunk concurrency is globally capped downstream).
|
||||
if (!urlLoadTried && modelUrls.length && Module._load_sidecar_from_source_c) {
|
||||
urlLoadTried = true;
|
||||
window.beginLoadProgress();
|
||||
registerUrlSource(modelUrl).then(function(sid) {
|
||||
Module._clear_scene_c(); // replace the embedded sample
|
||||
Module._load_sidecar_from_source_c(sid);
|
||||
}).catch(function(e) {
|
||||
statusEl.textContent += 'url load failed: ' + e + '\n';
|
||||
statusEl.classList.add('error');
|
||||
window.beginLoadProgress(modelUrls.length);
|
||||
Module._clear_scene_c(); // replace the embedded sample once
|
||||
modelUrls.forEach(function(url) {
|
||||
registerUrlSource(url).then(function(sid) {
|
||||
Module._load_sidecar_from_source_c(sid);
|
||||
}).catch(function(e) {
|
||||
statusEl.textContent += 'url load failed (' + url + '): ' + e + '\n';
|
||||
statusEl.classList.add('error');
|
||||
});
|
||||
});
|
||||
}
|
||||
window.updateLoadProgress();
|
||||
@@ -152,38 +174,63 @@
|
||||
// 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 progEl = document.getElementById('progress');
|
||||
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 loadActive = false;
|
||||
window.beginLoadProgress = function() {
|
||||
var expectedModels = 1;
|
||||
// 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%';
|
||||
progEl.style.display = 'block';
|
||||
textEl.style.display = 'block';
|
||||
textEl.textContent = 'Loading model…';
|
||||
segsEl.innerHTML = '';
|
||||
progEl.style.display = 'block';
|
||||
panelEl.style.display = 'block';
|
||||
summaryEl.textContent = 'Loading ' + expectedModels +
|
||||
' model' + (expectedModels === 1 ? '' : 's') + '…';
|
||||
};
|
||||
function endLoadProgress() { progEl.style.display = 'none'; textEl.style.display = 'none'; }
|
||||
function endLoadProgress() { progEl.style.display = 'none'; panelEl.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 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);
|
||||
if (T === 0) { // still fetching critical metadata
|
||||
fillEl.style.width = '6%';
|
||||
textEl.textContent = 'Loading model… ' + mb + ' MB';
|
||||
return;
|
||||
// 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);
|
||||
}
|
||||
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';
|
||||
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, 900);
|
||||
setTimeout(endLoadProgress, 1200);
|
||||
return;
|
||||
}
|
||||
textEl.textContent = 'Loading geometry — ' + R + ' / ' + T + ' chunks · ' + mb + ' MB';
|
||||
summaryEl.textContent = 'Loading ' + N + ' model' + (N === 1 ? '' : 's') + ' — ' +
|
||||
done + ' done · ' + (mc - done) + ' streaming · ' + (N - mc) + ' waiting · ' + mb + ' MB';
|
||||
};
|
||||
|
||||
// File-browse loading (#88, byte-range). The picked File object is stashed
|
||||
@@ -211,8 +258,11 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Replace: N = picked files. Add: existing models + picked files.
|
||||
var existing = (pendingMode === 'add' && Module._ifcv_model_count_c)
|
||||
? Module._ifcv_model_count_c() : 0;
|
||||
if (pendingMode === 'replace') Module._clear_scene_c();
|
||||
window.beginLoadProgress();
|
||||
window.beginLoadProgress(existing + files.length);
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var sid = registerFileSource(files[i]);
|
||||
Module._load_sidecar_from_source_c(sid);
|
||||
|
||||
@@ -3599,6 +3599,29 @@ void ViewportCore::streamingProgress(int& resident_chunks, int& total_chunks) co
|
||||
}
|
||||
}
|
||||
|
||||
int ViewportCore::streamingModelCount() const {
|
||||
return int(models_gpu_.size());
|
||||
}
|
||||
|
||||
void ViewportCore::streamingModelProgress(int idx, int& resident_chunks,
|
||||
int& total_chunks) const {
|
||||
resident_chunks = 0;
|
||||
total_chunks = 0;
|
||||
if (idx < 0 || idx >= int(models_gpu_.size())) return;
|
||||
// Order by model_id (= load order) so a model keeps the same UI slot as it
|
||||
// streams, instead of hopping with unordered_map iteration order.
|
||||
std::vector<std::uint32_t> ids;
|
||||
ids.reserve(models_gpu_.size());
|
||||
for (const auto& [mid, m] : models_gpu_) ids.push_back(mid);
|
||||
std::sort(ids.begin(), ids.end());
|
||||
auto it = models_gpu_.find(ids[std::size_t(idx)]);
|
||||
if (it == models_gpu_.end()) return;
|
||||
for (const auto& c : it->second.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()) {
|
||||
|
||||
@@ -395,6 +395,14 @@ public:
|
||||
// (still in the metadata phase). Cheap; safe to poll every frame.
|
||||
void streamingProgress(int& resident_chunks, int& total_chunks) const;
|
||||
|
||||
// Per-model progress for a federation loading UI. count() is how many
|
||||
// models have metadata (are in the scene); progress(idx,…) gives the
|
||||
// idx-th model's resident/total chunks, ordered by model_id (= load order)
|
||||
// so each model keeps a stable UI slot as it streams.
|
||||
int streamingModelCount() const;
|
||||
void streamingModelProgress(int idx, 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
|
||||
|
||||
Reference in New Issue
Block a user