mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 15:53:00 +00:00
b7f1f1728f
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>
274 lines
12 KiB
HTML
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. <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>
|