2026-06-04 18:58:34 +10:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<title>IfcViewer (web)</title>
|
|
|
|
|
<style>
|
|
|
|
|
html, body { margin: 0; height: 100%; background: #0f1117; color: #c8ccd6;
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
|
|
|
#viewer-canvas { display: block; width: 100vw; height: 100vh; outline: none;
|
|
|
|
|
background: #1a1d24; }
|
2026-06-29 11:33:14 +10:00
|
|
|
/* Log overlay sits bottom-left and never eats pointer events (so it
|
|
|
|
|
can't block orbit drags over the canvas). It auto-scrolls to the
|
|
|
|
|
newest line. Capped small; collapses further once the app is live. */
|
|
|
|
|
#status { position: fixed; bottom: 8px; left: 12px;
|
|
|
|
|
max-width: min(60vw, 680px); max-height: 28vh; overflow-y: auto;
|
|
|
|
|
font-size: 11px;
|
2026-06-09 17:16:14 +10:00
|
|
|
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
|
|
|
|
|
background: rgba(20,22,28,.78); padding: 6px 10px; border-radius: 4px;
|
2026-06-29 11:33:14 +10:00
|
|
|
white-space: pre-wrap; pointer-events: none; }
|
|
|
|
|
#status.ready { max-height: 4.5em; opacity: .5; }
|
2026-06-04 18:58:34 +10:00
|
|
|
#status.error { background: rgba(120,30,30,.85); color: #fff; }
|
2026-06-29 11:33:14 +10:00
|
|
|
/* Errors re-expand and re-opaque even after the ready-collapse. */
|
|
|
|
|
#status.ready.error { max-height: 28vh; opacity: 1; }
|
2026-07-01 10:03:24 +10:00
|
|
|
#open-btn, #add-btn { position: fixed; top: 8px; z-index: 10;
|
2026-06-29 11:19:49 +10:00
|
|
|
background: #2b6cb0; color: #fff; border: none; padding: 6px 12px;
|
|
|
|
|
border-radius: 4px; font-size: 12px; cursor: pointer; }
|
2026-07-01 10:03:24 +10:00
|
|
|
#open-btn { right: 12px; }
|
|
|
|
|
#add-btn { right: 120px; background: #2d3748; }
|
2026-06-29 11:19:49 +10:00
|
|
|
#open-btn:hover { background: #3182ce; }
|
2026-07-01 10:03:24 +10:00
|
|
|
#add-btn:hover { background: #3b465c; }
|
2026-06-29 11:19:49 +10:00
|
|
|
#file-input { display: none; }
|
2026-07-01 08:54:42 +10:00
|
|
|
/* 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; }
|
2026-06-04 18:58:34 +10:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<canvas id="viewer-canvas" width="1280" height="800"></canvas>
|
2026-07-01 08:54:42 +10:00
|
|
|
<div id="progress"><div id="progress-fill"></div></div>
|
|
|
|
|
<div id="progress-text"></div>
|
2026-07-01 10:03:24 +10:00
|
|
|
<button id="add-btn" title="Add file(s) to the current scene (federation)">Add</button>
|
2026-06-29 11:19:49 +10:00
|
|
|
<button id="open-btn">Open .ifcview…</button>
|
2026-07-01 10:03:24 +10:00
|
|
|
<input id="file-input" type="file" accept=".ifcview" multiple>
|
2026-06-04 18:58:34 +10:00
|
|
|
<div id="status">Starting…</div>
|
|
|
|
|
<script>
|
|
|
|
|
// Emscripten Module hook: route stderr to the status overlay so any
|
|
|
|
|
// wgpu init failure (no WebGPU, canvas missing, etc.) is visible
|
|
|
|
|
// without opening devtools.
|
|
|
|
|
var statusEl = document.getElementById('status');
|
|
|
|
|
var Module = {
|
|
|
|
|
canvas: document.getElementById('viewer-canvas'),
|
2026-06-09 17:16:14 +10:00
|
|
|
// Keep the wasm runtime alive after main() returns so the
|
|
|
|
|
// Dawn-web RequestAdapter/RequestDevice promise callbacks
|
|
|
|
|
// (queued from main) actually land. Without this flag the
|
|
|
|
|
// runtime tears down at end-of-main and the callbacks never
|
|
|
|
|
// fire — symptom: adapter cb fires (synchronous-ish on the
|
|
|
|
|
// first JS tick) but device cb does not. EXIT_RUNTIME=0 in
|
|
|
|
|
// CMakeLists is the build-time half; this is the runtime half.
|
|
|
|
|
noExitRuntime: true,
|
2026-06-04 18:58:34 +10:00
|
|
|
print: function(t) { console.log(t); },
|
|
|
|
|
printErr: function(t) {
|
|
|
|
|
console.warn(t);
|
2026-06-09 17:16:14 +10:00
|
|
|
// Accumulate every stderr line so the init sequence is visible
|
|
|
|
|
// even when the wasm hangs partway. The first time something is
|
|
|
|
|
// printed we drop the "Starting…" placeholder.
|
|
|
|
|
if (statusEl.textContent === 'Starting…' ||
|
|
|
|
|
statusEl.textContent === 'wasm loaded — waiting for WebGPU') {
|
|
|
|
|
statusEl.textContent = '';
|
|
|
|
|
}
|
|
|
|
|
statusEl.textContent += t + '\n';
|
|
|
|
|
statusEl.scrollTop = statusEl.scrollHeight;
|
2026-06-04 18:58:34 +10:00
|
|
|
if (/fail|error|null/i.test(t)) statusEl.classList.add('error');
|
|
|
|
|
},
|
|
|
|
|
onRuntimeInitialized: function() {
|
|
|
|
|
statusEl.textContent = 'wasm loaded — waiting for WebGPU';
|
2026-06-09 17:16:14 +10:00
|
|
|
// RAF loop. Polls for Module._app_ptr (set by C once the wgpu
|
|
|
|
|
// device callback completes init) and only then drives the C
|
|
|
|
|
// tick. Living in shell.html means the loop is set up from a
|
|
|
|
|
// clean JS top-level, NOT nested inside Dawn-web's Promise.then
|
|
|
|
|
// chain — which is the configuration that stalls device-callback
|
|
|
|
|
// delivery (verified during web bring-up).
|
2026-06-29 11:33:14 +10:00
|
|
|
var collapsedOnce = false;
|
2026-06-30 12:44:37 +10:00
|
|
|
var urlLoadTried = false;
|
|
|
|
|
var modelUrl = new URLSearchParams(location.search).get('model');
|
2026-06-09 17:16:14 +10:00
|
|
|
function shellTick() {
|
|
|
|
|
if (Module._app_ptr && Module._raf_tick_c) {
|
2026-06-29 11:33:14 +10:00
|
|
|
// First time the app goes live, collapse the log overlay so it
|
|
|
|
|
// stops covering the viewport.
|
|
|
|
|
if (!collapsedOnce) { statusEl.classList.add('ready'); collapsedOnce = true; }
|
2026-06-30 12:44:37 +10:00
|
|
|
// ?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.
|
2026-07-01 10:03:24 +10:00
|
|
|
if (!urlLoadTried && modelUrl && Module._load_sidecar_from_source_c) {
|
2026-06-30 12:44:37 +10:00
|
|
|
urlLoadTried = true;
|
2026-07-01 08:54:42 +10:00
|
|
|
window.beginLoadProgress();
|
2026-07-01 10:03:24 +10:00
|
|
|
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');
|
|
|
|
|
});
|
2026-06-30 12:44:37 +10:00
|
|
|
}
|
2026-07-01 08:54:42 +10:00
|
|
|
window.updateLoadProgress();
|
2026-06-09 17:16:14 +10:00
|
|
|
Module._raf_tick_c(Module._app_ptr);
|
|
|
|
|
}
|
|
|
|
|
requestAnimationFrame(shellTick);
|
|
|
|
|
}
|
|
|
|
|
requestAnimationFrame(shellTick);
|
2026-06-04 18:58:34 +10:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if (!navigator.gpu) {
|
|
|
|
|
statusEl.textContent = 'navigator.gpu is missing — open in a browser with WebGPU enabled';
|
|
|
|
|
statusEl.classList.add('error');
|
|
|
|
|
}
|
2026-06-29 11:19:49 +10:00
|
|
|
|
2026-07-01 10:03:24 +10:00
|
|
|
// --- Byte-source registry (multi-file federation) -------------------------
|
|
|
|
|
// Each model streams from its own source: a picked File (Blob.slice) or a
|
|
|
|
|
// remote URL (HTTP Range), registered here and read lazily by the wasm side
|
|
|
|
|
// via Module.__ifcvSources[id]. URLs are sized up front (HEAD, else a 0-0
|
|
|
|
|
// Range's Content-Range) so the loader can bound its reads.
|
|
|
|
|
Module.__ifcvSources = Module.__ifcvSources || [];
|
|
|
|
|
function registerFileSource(file) {
|
|
|
|
|
var sid = Module.__ifcvSources.length;
|
|
|
|
|
Module.__ifcvSources.push({ file: file, url: null, size: file.size });
|
|
|
|
|
return sid;
|
|
|
|
|
}
|
|
|
|
|
function registerUrlSource(url) {
|
|
|
|
|
return fetch(url, { method: 'HEAD' }).then(function(resp) {
|
|
|
|
|
var len = resp.ok ? parseInt(resp.headers.get('Content-Length') || '0', 10) : 0;
|
|
|
|
|
if (len > 0) return len;
|
|
|
|
|
return fetch(url, { headers: { Range: 'bytes=0-0' } }).then(function(r2) {
|
|
|
|
|
var cr = r2.headers.get('Content-Range'); // "bytes 0-0/12345"
|
|
|
|
|
return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0;
|
|
|
|
|
});
|
|
|
|
|
}).then(function(size) {
|
|
|
|
|
if (!size) throw new Error('could not size ' + url + ' (need HEAD or Range)');
|
|
|
|
|
var sid = Module.__ifcvSources.length;
|
|
|
|
|
Module.__ifcvSources.push({ file: null, url: url, size: size });
|
|
|
|
|
return sid;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 08:54:42 +10:00
|
|
|
// --- 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';
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-29 17:43:53 +10:00
|
|
|
// 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
|
|
|
|
|
// sidecar stays in the browser File object and only chunk-sized slices
|
|
|
|
|
// ever cross into wasm. No drag-drop: that needs an X11 drag source (a
|
|
|
|
|
// file manager), which a minimal WM may not provide; the native file
|
|
|
|
|
// chooser this button opens is WM-independent.
|
2026-07-01 10:03:24 +10:00
|
|
|
// "Open" replaces the scene with the picked file(s); "Add" appends them to
|
|
|
|
|
// the current scene (federation). Multiple files can be picked at once. Each
|
|
|
|
|
// File is registered as its own byte-source (kept alive in __ifcvSources for
|
|
|
|
|
// lazy Blob.slice reads) and streamed independently.
|
2026-06-29 11:19:49 +10:00
|
|
|
var openBtn = document.getElementById('open-btn');
|
2026-07-01 10:03:24 +10:00
|
|
|
var addBtn = document.getElementById('add-btn');
|
2026-06-29 11:19:49 +10:00
|
|
|
var fileInput = document.getElementById('file-input');
|
2026-07-01 10:03:24 +10:00
|
|
|
var pendingMode = 'replace';
|
|
|
|
|
openBtn.addEventListener('click', function() { pendingMode = 'replace'; fileInput.click(); });
|
|
|
|
|
addBtn.addEventListener('click', function() { pendingMode = 'add'; fileInput.click(); });
|
2026-06-29 11:19:49 +10:00
|
|
|
fileInput.addEventListener('change', function(ev) {
|
2026-07-01 10:03:24 +10:00
|
|
|
var files = ev.target.files;
|
|
|
|
|
if (!files || !files.length) return;
|
|
|
|
|
if (!Module._load_sidecar_from_source_c) {
|
2026-06-29 11:19:49 +10:00
|
|
|
statusEl.textContent += 'viewer not ready yet — wait for WebGPU init\n';
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-29 17:43:53 +10:00
|
|
|
try {
|
2026-07-01 10:03:24 +10:00
|
|
|
if (pendingMode === 'replace') Module._clear_scene_c();
|
2026-07-01 08:54:42 +10:00
|
|
|
window.beginLoadProgress();
|
2026-07-01 10:03:24 +10:00
|
|
|
for (var i = 0; i < files.length; i++) {
|
|
|
|
|
var sid = registerFileSource(files[i]);
|
|
|
|
|
Module._load_sidecar_from_source_c(sid);
|
|
|
|
|
}
|
2026-06-29 17:43:53 +10:00
|
|
|
} catch (e) {
|
|
|
|
|
statusEl.textContent += 'load failed: ' + e + '\n';
|
|
|
|
|
statusEl.classList.add('error');
|
|
|
|
|
}
|
|
|
|
|
fileInput.value = ''; // let the same file be re-picked
|
2026-06-29 11:19:49 +10:00
|
|
|
});
|
2026-06-04 18:58:34 +10:00
|
|
|
</script>
|
|
|
|
|
<!-- IfcViewerWeb.js is emitted alongside this shell by the emcc build;
|
|
|
|
|
`--shell-file` injects this HTML around it. -->
|
|
|
|
|
{{{ SCRIPT }}}
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|