Files
IfcOpenShell/src/ifcviewer-web/web/embedded.html
T
Dion Moult b7f1f1728f ifcviewer-web: expose frame stats and per-model unload/load to the page
The memory work (cache budget, pressure handling, unloadModel) lives in
ViewportCore and so already ran in the wasm, but the page could not see
or use any of it: the web host had no onFrameStats, and there were no
bindings for residency.

- WebViewportHost latches the last FrameStats; ifcv_get_frame_stats_c
  hands them to JS as doubles, and viewer.stats() returns {fps,
  frameTimeMs, objects, triangles, drawCalls, vram{used, capacity,
  budget}, workingSet{chunks, chunksMissing, missingBytes}} — the same
  figures BonsaiViewer's status bar shows. Device-wide VRAM is omitted:
  there is no query for it on web.
- viewer.unloadModel / loadModel / modelUnloaded / modelVramBytes, keyed
  by source id like the other per-model calls.
- The demo page shows a GPU memory line that turns into a "full: N of M
  visible chunks not loaded" notice once a shortfall persists for 3 s,
  and each model's MB with an Unload/Load button.
- memory.spec.mjs covers stats() and the unload/load round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:42:13 +10:00

274 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcViewer (web) — embedded / JS integration</title>
<style>
:root { color-scheme: dark; }
html, body { margin: 0; height: 100%; background: #0f1117; color: #c8ccd6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
header { padding: 12px 16px; border-bottom: 1px solid #232833; }
header h1 { margin: 0; font-size: 15px; font-weight: 600; }
header p { margin: 4px 0 0; font-size: 12px; color: #8a93a6; }
header a { color: #7f9bd6; }
.layout { display: flex; gap: 16px; padding: 16px; align-items: flex-start;
flex-wrap: wrap; }
/* The viewer is a normal, sized DOM box — NOT fullscreen. */
#viewer-box { position: relative; width: 640px; height: 460px; max-width: 100%;
border: 1px solid #232833; border-radius: 6px; overflow: hidden;
background: #1a1d24; }
#viewer-canvas { display: block; width: 100%; height: 100%; outline: none; }
#marquee { position: absolute; display: none; z-index: 5; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
.sidebar { flex: 1 1 300px; min-width: 280px; display: flex; flex-direction: column; gap: 14px; }
.card { border: 1px solid #232833; border-radius: 6px; background: #151821; }
.card h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase; color: #8a93a6;
border-bottom: 1px solid #232833; }
.card .body { padding: 12px; }
.row { display: flex; gap: 8px; align-items: center; }
.row + .row { margin-top: 8px; }
input[type=text] { flex: 1; min-width: 0; background: #0f1117; color: #c8ccd6;
border: 1px solid #2b3244; border-radius: 4px; padding: 6px 8px; font-size: 12px; }
button { background: #2b6cb0; color: #fff; border: none; padding: 6px 12px;
border-radius: 4px; font-size: 12px; cursor: pointer; }
button.secondary { background: #2d3748; color: #c8ccd6; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .5; cursor: default; filter: none; }
ul#model-list { list-style: none; margin: 0; padding: 0; font-size: 12px; }
ul#model-list li { padding: 7px 12px; border-bottom: 1px solid #1c202b; }
ul#model-list li:last-child { border-bottom: none; }
ul#model-list .name { display: flex; justify-content: space-between; gap: 8px; }
ul#model-list .name b { font-weight: 600; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; }
ul#model-list .pct { color: #8a93a6; flex: 0 0 auto; }
ul#model-list .mem { color: #8a93a6; font-size: 11px; margin-left: 8px; flex: 0 0 auto; }
ul#model-list .mem button { font-size: 11px; padding: 1px 6px; margin-left: 6px; }
ul#model-list li.unloaded b { font-style: italic; color: #6f7988; }
#gpu-memory.full { color: #e0a040; }
.bar { height: 4px; margin-top: 5px; border-radius: 2px; background: #232833; overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0%; background: #3182ce; }
.empty { color: #6f7988; font-size: 12px; padding: 4px 0; }
.sel-field { display: flex; gap: 8px; font-size: 13px; }
.sel-field + .sel-field { margin-top: 6px; }
.sel-field label { flex: 0 0 70px; color: #8a93a6; }
#sel-guid { font-family: ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
#sel-guid.none { color: #6f7988; }
#sel-model { word-break: break-all; }
.hint { font-size: 11px; color: #6f7988; margin-top: 6px; }
</style>
</head>
<body>
<header>
<h1>IfcOpenShell web viewer — JavaScript integration</h1>
<p>The viewer is an ordinary page element; the model list and selected GUID are
plain DOM updated from JS. &nbsp;<a href="IfcViewerWeb.html">↗ fullscreen example</a></p>
</header>
<div class="layout">
<!-- The viewer. The canvas MUST be id="viewer-canvas" (the wasm hard-codes
that selector). #marquee is the box-select rubber-band the wasm draws. -->
<div>
<div id="viewer-box">
<canvas id="viewer-canvas" width="1280" height="920"></canvas>
<div id="marquee"></div>
</div>
<div class="hint">Drag to orbit · scroll to zoom · <b>right-click to select</b> (drag right-click to box-select)</div>
</div>
<div class="sidebar">
<div class="card">
<h2>Add model (.ifcview)</h2>
<div class="body">
<div class="row">
<button id="browse-btn" class="secondary" disabled>Browse file(s)…</button>
<input id="file-input" type="file" accept=".ifcview" multiple style="display:none">
<button id="clear-btn" class="secondary" disabled>Clear</button>
</div>
<div class="row">
<input id="url-input" type="text" placeholder="https://…/model.ifcview" disabled>
<button id="url-btn" disabled>Add URL</button>
</div>
<div class="hint" id="status-hint">Starting WebGPU…</div>
</div>
</div>
<div class="card">
<h2>Models in scene</h2>
<ul id="model-list"><li class="empty">No models loaded.</li></ul>
<div class="hint" id="gpu-memory">GPU memory: —</div>
</div>
<div class="card">
<h2>Selected object</h2>
<div class="body">
<div class="sel-field"><label>Model</label><span id="sel-model"></span></div>
<div class="sel-field"><label>GlobalId</label><span id="sel-guid" class="none">Right-click an object in the viewer…</span></div>
</div>
</div>
</div>
</div>
<script src="IfcViewerWeb.js"></script>
<script src="ifcviewer.js"></script>
<script>
// JS-side model list. The wasm orders models by load, so a model's array
// index here matches its index in the C progress exports.
var models = [];
var userAddedAny = false; // once true, stop dropping the embedded sample
var listEl = document.getElementById('model-list');
var selGuidEl = document.getElementById('sel-guid');
var selModelEl = document.getElementById('sel-model');
var hintEl = document.getElementById('status-hint');
function renderList() {
if (!models.length) {
listEl.innerHTML = '<li class="empty">No models loaded.</li>';
return;
}
listEl.innerHTML = '';
models.forEach(function (m, i) {
var li = document.createElement('li');
if (m.unloaded) li.className = 'unloaded';
var pct = m.total > 0 ? Math.round(100 * m.resident / m.total) : 0;
var label = m.unloaded ? 'unloaded' : m.total > 0 ? pct + '%' : '…';
var mem = m.unloaded ? '' : Math.round(m.vram / (1024 * 1024)) + ' MB';
li.innerHTML =
'<div class="name"><b title="' + m.name + '">' + m.name + '</b>' +
'<span class="mem">' + mem + '<button data-i="' + i + '">' +
(m.unloaded ? 'Load' : 'Unload') + '</button></span>' +
'<span class="pct">' + label + '</span></div>' +
'<div class="bar"><i style="width:' + (m.unloaded ? 0 : pct) + '%"></i></div>';
listEl.appendChild(li);
});
}
// Unload frees a model's GPU memory while it stays in the scene — the lever
// when the GPU memory line reports chunks that cannot be loaded.
listEl.addEventListener('click', function (ev) {
var btn = ev.target.closest('button[data-i]');
if (!btn || !activeViewer) return;
var m = models[+btn.dataset.i];
if (!m || m.sid === undefined) return;
if (m.unloaded) {
if (!activeViewer.loadModel(m.sid)) { hintEl.textContent = 'Not enough GPU memory to load ' + m.name; return; }
} else {
activeViewer.unloadModel(m.sid);
}
m.unloaded = activeViewer.modelUnloaded(m.sid);
renderList();
});
var gpuMemEl = document.getElementById('gpu-memory');
var shortfallSince = 0;
var activeViewer = null; // set once IfcViewer.create resolves
function renderGpuMemory(viewer) {
var s = viewer.stats();
if (!s) return;
var mb = function (b) { return Math.round(b / (1024 * 1024)); };
var text = 'GPU memory: ' + mb(s.vram.usedBytes) + ' / ' + mb(s.vram.capacityBytes) + ' MB';
if (s.vram.budgetBytes && s.vram.budgetBytes !== s.vram.capacityBytes) {
text += ' (budget ' + mb(s.vram.budgetBytes) + ')';
}
// A few missing chunks right after a camera move are normal; a shortfall
// that persists means the view does not fit — say so.
var now = performance.now();
if (!s.workingSet.chunksMissing) shortfallSince = 0;
else if (!shortfallSince) shortfallSince = now;
var full = shortfallSince && now - shortfallSince > 3000;
if (full) {
text += ' — full: ' + s.workingSet.chunksMissing + ' of ' + s.workingSet.chunks +
' visible chunks (' + mb(s.workingSet.missingBytes) + ' MB) not loaded. Unload a model to make room.';
}
if (gpuMemEl.textContent !== text) gpuMemEl.textContent = text;
gpuMemEl.classList.toggle('full', !!full);
}
function setSelection(guid, modelName) {
selModelEl.textContent = modelName || '—';
if (guid) { selGuidEl.textContent = guid; selGuidEl.classList.remove('none'); }
else { selGuidEl.textContent = 'Right-click an object in the viewer…'; selGuidEl.classList.add('none'); }
}
if (!navigator.gpu) hintEl.textContent = 'navigator.gpu missing — needs a WebGPU browser';
var canvas = document.getElementById('viewer-canvas');
canvas.addEventListener('contextmenu', function (ev) { ev.preventDefault(); });
IfcViewer.create({
canvas: canvas,
// Per-frame: refresh each model's streaming progress in the list.
onFrame: function (viewer) {
// Start empty: drop the wasm's embedded sample cube (kept for the
// fullscreen page/tests) so it doesn't linger or skew the first fit-all.
// Keep clearing until it's gone; stop once the user adds their own model.
if (!userAddedAny && viewer.modelCount() > 0) viewer.clearScene();
if (!models.length) return;
renderGpuMemory(viewer);
var changed = false;
for (var i = 0; i < models.length; i++) {
var p = viewer.modelProgress(i);
var vram = models[i].sid !== undefined ? viewer.modelVramBytes(models[i].sid) : 0;
if (p.resident !== models[i].resident || p.total !== models[i].total
|| Math.round(vram / (1024 * 1024)) !== Math.round(models[i].vram / (1024 * 1024))) {
models[i].resident = p.resident; models[i].total = p.total; models[i].vram = vram; changed = true;
}
}
if (changed) renderList();
},
}).then(function (viewer) {
activeViewer = viewer;
// Report the picked object's model + IFC GlobalId in our own DOM (empty on
// deselect). sel.modelIndex indexes our JS model list (load order).
viewer.onSelect(function (sel) {
var name = (sel.modelIndex !== null && models[sel.modelIndex]) ? models[sel.modelIndex].name : null;
setSelection(sel.guid, name);
});
var browseBtn = document.getElementById('browse-btn');
var clearBtn = document.getElementById('clear-btn');
var fileInput = document.getElementById('file-input');
var urlInput = document.getElementById('url-input');
var urlBtn = document.getElementById('url-btn');
function addModelEntry(name, sid) {
models.push({ name: name, sid: sid, resident: 0, total: 0, vram: 0, unloaded: false });
renderList();
}
viewer.ready.then(function () {
hintEl.textContent = 'Ready — add a .ifcview model.';
[browseBtn, clearBtn, urlInput, urlBtn].forEach(function (el) { el.disabled = false; });
});
browseBtn.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function (ev) {
if (ev.target.files.length) userAddedAny = true;
Array.prototype.forEach.call(ev.target.files, function (file) {
viewer.addFile(file).then(function (sid) { addModelEntry(file.name, sid); });
});
fileInput.value = '';
});
urlBtn.addEventListener('click', function () {
var url = urlInput.value.trim();
if (!url) return;
userAddedAny = true;
urlBtn.disabled = true;
viewer.addUrl(url).then(function (sid) {
addModelEntry(url.split('/').pop() || url, sid);
urlInput.value = '';
}).catch(function (e) {
hintEl.textContent = 'URL load failed: ' + e.message;
}).finally(function () { urlBtn.disabled = false; });
});
clearBtn.addEventListener('click', function () {
viewer.clearScene();
models = []; renderList(); setSelection(null);
});
});
</script>
</body>
</html>