Files
IfcOpenShell/src/ifcviewer-web/shell.html
T
Dion Moult 001476c5a9 ifcviewer-web: navigation parity — view all, XYZ views, ortho, zoom-to-selected
The camera math already lived in the shared ViewportCore; the desktop just
bound keys to it. The web build had no keyboard handler and no camera UI, so
none of it was reachable. Wire it up (Tier 1 + 2 of nav parity; fly mode is a
separate follow-up).

Shared core:
- setStandardView(StandardView) — named Front/Back/Left/Right/Top/Bottom wrapper
  over setStandardView(yaw,pitch), so the axis→angle mapping lives in one place.
- frameSelection() — lifts the desktop's "union selected AABBs → frameAabb(1.30)"
  focus logic out of ViewportWindow into the core. Desktop's focusOnSelectedObject
  and the X/Y/Z hotkeys now call these (DRY, behaviour unchanged).

Web:
- main_web gains a keydown handler matching the desktop bindings — Home=view all,
  F=zoom to selected, P=ortho toggle, X/Y/Z (+Shift=negative)=standard views —
  plus exported entry points (view_all_c / frame_selection_c / toggle_projection_c
  / projection_is_ortho_c / standard_view_c) for the toolbar.
- shell.html adds a bottom nav toolbar (Fit / Focus / Persp-Ortho / the six views)
  with the hotkeys in tooltips; the ortho button reflects state.

Verified: Z key and Front button both move the camera, ortho toggles render +
label, zero GPU errors; 111/111 unit + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:42:45 +10:00

320 lines
16 KiB
HTML

<!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; }
/* 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;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
background: rgba(20,22,28,.78); padding: 6px 10px; border-radius: 4px;
white-space: pre-wrap; pointer-events: none; }
#status.ready { max-height: 4.5em; opacity: .5; }
#status.error { background: rgba(120,30,30,.85); color: #fff; }
/* Errors re-expand and re-opaque even after the ready-collapse. */
#status.ready.error { max-height: 28vh; opacity: 1; }
#open-btn, #add-btn { position: fixed; top: 8px; z-index: 10;
background: #2b6cb0; color: #fff; border: none; padding: 6px 12px;
border-radius: 4px; font-size: 12px; cursor: pointer; }
#open-btn { right: 12px; }
#add-btn { right: 120px; background: #2d3748; }
#open-btn:hover { background: #3182ce; }
#add-btn:hover { background: #3b465c; }
#file-input { display: none; }
/* Navigation toolbar (bottom-centre): buttons mirror the desktop hotkeys. */
#nav-toolbar { position: fixed; bottom: 10px; left: 50%;
transform: translateX(-50%); z-index: 10; display: flex; gap: 4px;
background: rgba(20,22,28,.82); padding: 5px 6px; border-radius: 6px; }
#nav-toolbar button { background: #2d3748; color: #c8ccd6; border: none;
padding: 5px 9px; border-radius: 4px; font-size: 12px; cursor: pointer; }
#nav-toolbar button:hover { background: #3b465c; }
#nav-toolbar .sep { width: 1px; background: #3b465c; margin: 2px 3px; }
/* 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-panel { position: fixed; top: 10px; left: 50%;
transform: translateX(-50%); z-index: 20; font-size: 12px;
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-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>
<div id="nav-toolbar">
<button data-act="fit" title="Fit all (Home)">Fit</button>
<button data-act="focus" title="Zoom to selected (F)">Focus</button>
<button data-act="ortho" id="ortho-btn" title="Toggle orthographic / perspective (P)">Persp</button>
<span class="sep"></span>
<button data-view="0" title="Front (X)">Front</button>
<button data-view="1" title="Back (Shift+X)">Back</button>
<button data-view="2" title="Left (Shift+Y)">Left</button>
<button data-view="3" title="Right (Y)">Right</button>
<button data-view="4" title="Top (Z)">Top</button>
<button data-view="5" title="Bottom (Shift+Z)">Bottom</button>
</div>
<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'),
// 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,
print: function(t) { console.log(t); },
printErr: function(t) {
console.warn(t);
// 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;
if (/fail|error|null/i.test(t)) statusEl.classList.add('error');
},
onRuntimeInitialized: function() {
statusEl.textContent = 'wasm loaded — waiting for WebGPU';
// 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).
var collapsedOnce = false;
var urlLoadTried = false;
// 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; }
// 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(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();
Module._raf_tick_c(Module._app_ptr);
}
requestAnimationFrame(shellTick);
}
requestAnimationFrame(shellTick);
}
};
if (!navigator.gpu) {
statusEl.textContent = 'navigator.gpu is missing — open in a browser with WebGPU enabled';
statusEl.classList.add('error');
}
// --- 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;
});
}
// --- 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 panelEl = document.getElementById('progress-panel');
var summaryEl = document.getElementById('progress-summary');
var segsEl = document.getElementById('progress-segs');
var loadActive = false;
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%';
segsEl.innerHTML = '';
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'; }
window.updateLoadProgress = function() {
if (!loadActive) return;
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);
return;
}
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
// 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.
// "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.
var openBtn = document.getElementById('open-btn');
var addBtn = document.getElementById('add-btn');
var fileInput = document.getElementById('file-input');
var pendingMode = 'replace';
openBtn.addEventListener('click', function() { pendingMode = 'replace'; fileInput.click(); });
addBtn.addEventListener('click', function() { pendingMode = 'add'; fileInput.click(); });
fileInput.addEventListener('change', function(ev) {
var files = ev.target.files;
if (!files || !files.length) return;
if (!Module._load_sidecar_from_source_c) {
statusEl.textContent += 'viewer not ready yet — wait for WebGPU init\n';
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(existing + files.length);
for (var i = 0; i < files.length; i++) {
var sid = registerFileSource(files[i]);
Module._load_sidecar_from_source_c(sid);
}
} catch (e) {
statusEl.textContent += 'load failed: ' + e + '\n';
statusEl.classList.add('error');
}
fileInput.value = ''; // let the same file be re-picked
});
// Navigation toolbar → C camera calls (same core methods as the hotkeys).
function refreshOrthoLabel() {
var b = document.getElementById('ortho-btn');
if (b && Module._projection_is_ortho_c)
b.textContent = Module._projection_is_ortho_c() ? 'Ortho' : 'Persp';
}
document.querySelectorAll('#nav-toolbar button').forEach(function(b) {
b.addEventListener('click', function() {
if (!Module._view_all_c) return; // viewer not ready yet
var act = b.getAttribute('data-act');
var view = b.getAttribute('data-view');
if (act === 'fit') Module._view_all_c();
else if (act === 'focus') Module._frame_selection_c();
else if (act === 'ortho') { Module._toggle_projection_c(); refreshOrthoLabel(); }
else if (view !== null) Module._standard_view_c(parseInt(view, 10));
});
});
</script>
<!-- IfcViewerWeb.js is emitted alongside this shell by the emcc build;
`--shell-file` injects this HTML around it. -->
{{{ SCRIPT }}}
</body>
</html>