web: MODULARIZE build + embedded JS-integration example + selection callback

Restructure the web viewer so the wasm is a reusable module and add a
second example that drives it from ordinary page DOM.

Build:
- Emit IfcViewerWeb.js (a `createIfcViewer` factory, MODULARIZE) + .wasm
  instead of a single baked page (dropped --shell-file); copy the static
  example pages next to it at build time.
- Unbreak the web build: CameraMath.h / ViewportCore.cpp used
  boost::math::constants::pi just for pi, pulling all of boost/math into a
  header shared with the Emscripten build (no Boost in its sysroot). Replace
  with a constexpr kPiF — identical value, no dependency, desktop unaffected.

JS integration (web/ifcviewer.js):
- A small helper wraps the factory: boots the viewer on a canvas, runs the
  RAF loop from onRuntimeInitialized (NOT a post-await .then, which stalls
  Dawn-web's device callback and leaves the device half-initialised), and
  exposes addFile/addUrl, clearScene, model list/progress, and onSelect(...).
- ViewportCore/main_web emit each pick to JS via Module.__ifcvOnSelect
  (object id + IFC GlobalId + model index; empty on deselect); onSelect also
  dispatches an 'ifcviewer:select' DOM event.
- Fix input coords for a non-fullscreen canvas: mousemove/mouseup are
  window-targeted, so convert their coords to canvas-relative via the canvas
  client-rect origin (marquee + box-pick were offset when embedded).

Examples:
- IfcViewerWeb.html: the fullscreen viewer (same DOM/behaviour as before,
  now loading the module) — the Playwright smoke suite still targets it.
- embedded.html: a sized viewer with DOM outside it to add models (file or
  URL), list loaded models with streaming progress, and show the model +
  GlobalId of the clicked object. Starts empty (drops the wasm's embedded
  sample, which the fullscreen page/tests still use).
- index.html links both.

Federation note: the web viewer already streams multiple models into one
scene (a byte-source per file/URL); it doesn't need the desktop Federation
document for this. Verified: 11/11 web smoke tests pass; embedded example
loads models, reports the picked model + GUID, and the marquee aligns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-09 16:46:29 +10:00
parent b2ecfab86e
commit ed21dd7ecc
10 changed files with 783 additions and 424 deletions
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcViewer (web) — fullscreen</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; }
/* Marquee (box-select) rubber-band. Positioned in CSS px by main_web; never
eats pointer events so the drag keeps reaching the canvas. */
#marquee { position: fixed; display: none; z-index: 50; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
/* Log overlay sits bottom-left and never eats pointer events. */
#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; }
#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; }
#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 button.active { background: #2b6cb0; color: #fff; }
#nav-toolbar .sep { width: 1px; background: #3b465c; margin: 2px 3px; }
#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; }
#progress-track { position: relative; height: 8px; border-radius: 3px;
background: #232833; overflow: hidden; }
#progress-needed, #progress-loaded { position: absolute; left: 0; top: 0;
height: 100%; width: 0%; transition: width .2s ease; }
#progress-needed { background: #2b4a6b; }
#progress-loaded { background: #3182ce; }
#example-link { position: fixed; top: 8px; left: 12px; z-index: 10;
font-size: 12px; color: #7f9bd6; text-decoration: none; }
</style>
</head>
<body>
<canvas id="viewer-canvas" width="1280" height="800"></canvas>
<div id="marquee"></div>
<a id="example-link" href="embedded.html">↗ embedded / JS-integration example</a>
<div id="progress"><div id="progress-fill"></div></div>
<div id="progress-panel">
<div id="progress-summary"></div>
<div id="progress-track">
<div id="progress-needed"></div>
<div id="progress-loaded"></div>
</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>
<button data-act="fly" id="fly-btn" title="Fly / first-person — WASD+mouse (⇧F)">Fly</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>
<span class="sep"></span>
<button data-act="hide" title="Hide selected (H)">Hide</button>
<button data-act="isolate" title="Isolate selected (Shift+H)">Isolate</button>
<button data-act="showall" title="Show all (Alt+H)">Show all</button>
<button data-act="xray" id="xray-btn" title="X-ray — translucent everything (Alt+X)">X-ray</button>
<span class="sep"></span>
<button data-act="section" id="section-btn" title="Section tool — click a surface to cut (K)">Section</button>
<button data-act="clearcut" title="Clear all section cuts (Shift+K)">Clear cuts</button>
</div>
<div id="status">Starting…</div>
<script src="IfcViewerWeb.js"></script>
<script src="ifcviewer.js"></script>
<script>
var statusEl = document.getElementById('status');
function routeStatus(t, isErr) {
if (statusEl.textContent === 'Starting…') statusEl.textContent = '';
statusEl.textContent += t + '\n';
statusEl.scrollTop = statusEl.scrollHeight;
if (isErr || /fail|error|null/i.test(t)) statusEl.classList.add('error');
}
if (!navigator.gpu) {
statusEl.textContent = 'navigator.gpu is missing — open in a browser with WebGPU enabled';
statusEl.classList.add('error');
}
// RMB is the select/marquee button in the Web nav preset — suppress the
// browser context menu over the canvas.
var viewerCanvas = document.getElementById('viewer-canvas');
viewerCanvas.addEventListener('contextmenu', function (ev) { ev.preventDefault(); });
// --- Streaming loading bar (driven off the C progress exports) ------------
var progEl = document.getElementById('progress');
var fillEl = document.getElementById('progress-fill');
var panelEl = document.getElementById('progress-panel');
var summaryEl = document.getElementById('progress-summary');
var neededEl = document.getElementById('progress-needed');
var loadedEl = document.getElementById('progress-loaded');
var loadActive = false, expectedModels = 1, caughtUpAt = 0;
function beginLoadProgress(nModels) {
loadActive = true; expectedModels = Math.max(1, nModels || 1); caughtUpAt = 0;
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'; loadActive = false;
}
function fmtMB(b) { return (b / 1e6).toFixed(b < 1e8 ? 1 : 0); }
function updateLoadProgress(viewer) {
var b = viewer.bytes();
var mc = viewer.modelCount();
var dlMB = (viewer.module.__ifcvBytesLoaded || 0) / 1e6;
var overhead = b.total === 0;
var streaming = b.needed > b.loaded + 1;
if (overhead || streaming) { loadActive = true; caughtUpAt = 0; }
if (!loadActive) return;
progEl.style.display = 'block'; panelEl.style.display = 'block';
if (overhead) {
var frac = expectedModels > 0 ? mc / expectedModels : 0;
neededEl.style.width = '100%';
loadedEl.style.width = (100 * frac) + '%';
fillEl.style.width = Math.max(4, 100 * frac) + '%';
summaryEl.textContent = 'Loading model data — ' + dlMB.toFixed(1) + ' MB · ' +
mc + ' / ' + expectedModels + ' models ready';
return;
}
neededEl.style.width = (100 * b.needed / b.total) + '%';
loadedEl.style.width = (100 * b.loaded / b.total) + '%';
fillEl.style.width = (b.needed > 0 ? Math.round(100 * b.loaded / b.needed) : 100) + '%';
var pctNeeded = Math.round(100 * b.needed / b.total);
var more = (mc < expectedModels) ? ' · ' + mc + '/' + expectedModels + ' models' : '';
if (streaming) {
summaryEl.textContent = 'Loading ' + fmtMB(b.loaded) + ' / ' + fmtMB(b.needed) +
' MB for this view · ' + pctNeeded + '% of ' + fmtMB(b.total) + ' MB total' + more;
} else {
summaryEl.textContent = (pctNeeded >= 99 ? 'Loaded ' : 'View loaded — ') +
fmtMB(b.loaded) + ' MB · ' + pctNeeded + '% of ' + fmtMB(b.total) + ' MB total' + more;
if (!caughtUpAt) caughtUpAt = performance.now();
if (performance.now() - caughtUpAt > 1500) endLoadProgress();
}
}
function syncButton(id, active) {
var el = document.getElementById(id);
if (el) el.classList.toggle('active', !!active);
}
IfcViewer.create({
canvas: viewerCanvas,
exposeAsModuleGlobal: true, // window.Module — used by the smoke tests
print: function (t) { console.log(t); },
// The wasm logs (incl. [info]) come through stderr; only redden the status
// box on actual error/failure lines, not on every info message.
printErr: function (t) { console.warn(t); routeStatus(t); },
onReady: function (viewer) {
statusEl.classList.add('ready');
// Auto-load ?model= / ?models= sidecars as a federation (one-shot).
var qs = new URLSearchParams(location.search);
var urls = qs.getAll('model');
var csv = qs.get('models');
if (csv) urls = urls.concat(csv.split(',').map(function (s) { return s.trim(); }).filter(Boolean));
if (urls.length) {
beginLoadProgress(urls.length);
viewer.clearScene(); // replace the embedded sample once
urls.forEach(function (url) {
viewer.addUrl(url).catch(function (e) { routeStatus('url load failed (' + url + '): ' + e, true); });
});
}
},
onFrame: function (viewer) {
updateLoadProgress(viewer);
var M = viewer.module;
syncButton('fly-btn', M._fly_is_active_c && M._fly_is_active_c());
syncButton('xray-btn', M._xray_is_active_c && M._xray_is_active_c());
syncButton('section-btn', M._section_is_active_c && M._section_is_active_c());
},
}).then(function (viewer) {
// File open (replace) / add (append). Multiple files → a federation.
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;
var existing = pendingMode === 'add' ? viewer.modelCount() : 0;
if (pendingMode === 'replace') viewer.clearScene();
beginLoadProgress(existing + files.length);
for (var i = 0; i < files.length; i++) viewer.addFile(files[i]);
fileInput.value = '';
});
var orthoBtn = document.getElementById('ortho-btn');
document.querySelectorAll('#nav-toolbar button').forEach(function (b) {
b.addEventListener('click', function () {
var M = viewer.module;
var act = b.getAttribute('data-act');
var view = b.getAttribute('data-view');
if (act === 'fit') viewer.viewAll();
else if (act === 'focus') viewer.frameSelection();
else if (act === 'ortho') { M._toggle_projection_c(); orthoBtn.textContent = M._projection_is_ortho_c() ? 'Ortho' : 'Persp'; }
else if (act === 'fly') M._toggle_fly_c();
else if (act === 'hide') M._hide_selected_c();
else if (act === 'isolate') M._isolate_selected_c();
else if (act === 'showall') M._show_all_c();
else if (act === 'xray') M._toggle_xray_c();
else if (act === 'section') M._toggle_section_c();
else if (act === 'clearcut') M._clear_section_c();
else if (view !== null) M._standard_view_c(parseInt(view, 10));
});
});
});
</script>
</body>
</html>
+216
View File
@@ -0,0 +1,216 @@
<!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; }
.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>
<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');
var pct = m.total > 0 ? Math.round(100 * m.resident / m.total) : 0;
var label = m.total > 0 ? pct + '%' : '…';
li.innerHTML =
'<div class="name"><b title="' + m.name + '">' + m.name + '</b>' +
'<span class="pct">' + label + '</span></div>' +
'<div class="bar"><i style="width:' + pct + '%"></i></div>';
listEl.appendChild(li);
});
}
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;
var changed = false;
for (var i = 0; i < models.length; i++) {
var p = viewer.modelProgress(i);
if (p.resident !== models[i].resident || p.total !== models[i].total) {
models[i].resident = p.resident; models[i].total = p.total; changed = true;
}
}
if (changed) renderList();
},
}).then(function (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) { models.push({ name: name, resident: 0, total: 0 }); 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 () { addModelEntry(file.name); });
});
fileInput.value = '';
});
urlBtn.addEventListener('click', function () {
var url = urlInput.value.trim();
if (!url) return;
userAddedAny = true;
urlBtn.disabled = true;
viewer.addUrl(url).then(function () {
addModelEntry(url.split('/').pop() || url);
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>
+169
View File
@@ -0,0 +1,169 @@
// ifcviewer.js — a small JavaScript integration layer over the Emscripten
// module (IfcViewerWeb.js). Load this AFTER IfcViewerWeb.js, which defines the
// global `createIfcViewer` factory.
//
// <script src="IfcViewerWeb.js"></script>
// <script src="ifcviewer.js"></script>
// <script>
// const viewer = await IfcViewer.create({ canvas: myCanvas });
// await viewer.ready; // GPU app is live
// viewer.onSelect(({ objectId, guid }) => …);
// await viewer.addFile(file, { replace: true });
// await viewer.addUrl('/model.ifcview'); // appends (federation)
// </script>
//
// The canvas element MUST have id="viewer-canvas" — the wasm side hard-codes
// that selector for its WebGPU surface and input handlers.
(function (global) {
'use strict';
// Resolve a remote sidecar's total size so the loader can bound its ranged
// reads: HEAD Content-Length, falling back to a 0-0 Range's Content-Range.
async function sizeUrl(url) {
const head = await fetch(url, { method: 'HEAD' });
const len = head.ok ? parseInt(head.headers.get('Content-Length') || '0', 10) : 0;
if (len > 0) return len;
const probe = await fetch(url, { headers: { Range: 'bytes=0-0' } });
const cr = probe.headers.get('Content-Range'); // "bytes 0-0/12345"
return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0;
}
// Boot a viewer bound to `opts.canvas`. Resolves to the API object once the
// wasm runtime is initialised; `api.ready` resolves once the GPU app is live.
async function create(opts) {
opts = opts || {};
const factory = opts.moduleFactory || global.createIfcViewer;
if (typeof factory !== 'function') {
throw new Error('createIfcViewer not found — load IfcViewerWeb.js first');
}
const selectListeners = [];
let api = null; // built below; the RAF loop only reads it after that
let live = false;
let resolveReady;
const ready = new Promise(function (r) { resolveReady = r; });
// The per-frame loop: poll for the app pointer (published once the GPU
// device is ready), then drive the C tick. It is registered from
// onRuntimeInitialized (a clean callback context) rather than after
// `await factory(...)` — that Promise.then continuation is exactly the
// nesting that stalls Dawn-web's device callback and leaves the GPU device
// half-initialised (every buffer then reports "invalid"). Learned during
// the original web bring-up; kept here deliberately.
function startLoop(Module) {
function tick() {
if (Module._app_ptr && Module._raf_tick_c) {
if (!live) {
live = true;
resolveReady(api);
if (opts.onReady) opts.onReady(api);
}
Module._raf_tick_c(Module._app_ptr);
if (opts.onFrame) opts.onFrame(api);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
const Module = await factory({
canvas: opts.canvas,
// Keep the runtime alive after main() returns so Dawn-web's async
// adapter/device callbacks land (they set Module._app_ptr).
noExitRuntime: true,
print: opts.print || function (t) { console.log(t); },
printErr: opts.printErr || function (t) { console.warn(t); },
onRuntimeInitialized: function () { startLoop(this); },
});
// Byte-source registry the wasm reads lazily: a picked File (Blob.slice) or
// a remote URL (HTTP Range). load_sidecar_from_source_c(sid) streams one.
Module.__ifcvSources = Module.__ifcvSources || [];
// The wasm calls this on every pick; (0, '', -1) means the selection was
// cleared. modelIndex is the picked object's model in load order (matches
// the modelProgress index), or -1.
Module.__ifcvOnSelect = function (objectId, guid, modelIndex) {
const detail = {
objectId: objectId >>> 0,
guid: guid || null,
modelIndex: (typeof modelIndex === 'number' && modelIndex >= 0) ? modelIndex : null,
};
selectListeners.forEach(function (cb) {
try { cb(detail); } catch (e) { console.error(e); }
});
try {
document.dispatchEvent(new CustomEvent('ifcviewer:select', { detail: detail }));
} catch (_) { /* older browsers */ }
};
// Some test harnesses / the fullscreen page want the raw module on window.
if (opts.exposeAsModuleGlobal) global.Module = Module;
function registerFile(file) {
const sid = Module.__ifcvSources.length;
Module.__ifcvSources.push({ file: file, url: null, size: file.size });
return sid;
}
async function registerUrl(url) {
const size = await sizeUrl(url);
if (!size) throw new Error('could not size ' + url + ' (need HEAD or Range support)');
const sid = Module.__ifcvSources.length;
Module.__ifcvSources.push({ file: null, url: url, size: size });
return sid;
}
api = {
module: Module,
ready: ready,
isLive: function () { return live; },
// Register a selection listener; returns an unsubscribe function.
onSelect: function (cb) {
selectListeners.push(cb);
return function () {
const i = selectListeners.indexOf(cb);
if (i >= 0) selectListeners.splice(i, 1);
};
},
// Scene / camera passthroughs.
clearScene: function () { if (Module._clear_scene_c) Module._clear_scene_c(); },
viewAll: function () { if (Module._view_all_c) Module._view_all_c(); },
frameSelection: function () { if (Module._frame_selection_c) Module._frame_selection_c(); },
// Model bookkeeping (ordered by load). Progress is per-model chunk counts.
modelCount: function () { return Module._ifcv_model_count_c ? Module._ifcv_model_count_c() : 0; },
modelProgress: function (i) {
return {
resident: Module._ifcv_model_resident_c ? Module._ifcv_model_resident_c(i) : 0,
total: Module._ifcv_model_total_c ? Module._ifcv_model_total_c(i) : 0,
};
},
bytes: function () {
return {
total: Module._ifcv_bytes_total_c ? Module._ifcv_bytes_total_c() : 0,
needed: Module._ifcv_bytes_needed_c ? Module._ifcv_bytes_needed_c() : 0,
loaded: Module._ifcv_bytes_loaded_c ? Module._ifcv_bytes_loaded_c() : 0,
};
},
registerFileSource: registerFile,
registerUrlSource: registerUrl,
// Add a model to the scene. `replace: true` drops the current scene first;
// otherwise it appends (a lightweight federation of streamed models).
addFile: async function (file, o) {
if (o && o.replace) this.clearScene();
Module._load_sidecar_from_source_c(registerFile(file));
},
addUrl: async function (url, o) {
if (o && o.replace) this.clearScene();
Module._load_sidecar_from_source_c(await registerUrl(url));
},
};
return api;
}
global.IfcViewer = { create: create, sizeUrl: sizeUrl };
})(window);
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcOpenShell web viewer — examples</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; min-height: 100%; background: #0f1117; color: #c8ccd6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 40px; }
.wrap { max-width: 640px; }
h1 { font-size: 20px; margin: 0 0 4px; }
p.lead { color: #8a93a6; margin: 0 0 24px; font-size: 13px; }
a.card { display: block; text-decoration: none; color: inherit;
border: 1px solid #232833; border-radius: 8px; padding: 16px 18px;
background: #151821; margin-bottom: 14px; }
a.card:hover { border-color: #2b6cb0; }
a.card h2 { margin: 0 0 4px; font-size: 15px; color: #dfe4ee; }
a.card p { margin: 0; font-size: 12px; color: #8a93a6; }
</style>
</head>
<body>
<div class="wrap">
<h1>IfcOpenShell web viewer</h1>
<p class="lead">Two examples of the same WebGPU viewer wasm (IfcViewerWeb.js),
loaded through the small <code>ifcviewer.js</code> integration helper.</p>
<a class="card" href="IfcViewerWeb.html">
<h2>Fullscreen viewer →</h2>
<p>The viewer fills the window with an overlay toolbar. Open/add .ifcview
files, or auto-load remote models with <code>?model=URL</code>.</p>
</a>
<a class="card" href="embedded.html">
<h2>Embedded viewer + JavaScript integration →</h2>
<p>The viewer is a sized page element. Plain DOM outside it adds models
(file or URL), lists the loaded models with streaming progress, and shows
the GlobalId of whatever you click in the scene.</p>
</a>
</div>
</body>
</html>