From ed21dd7ecc0de488163fe7916e091476e7ed574f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 16:46:29 +1000 Subject: [PATCH] web: MODULARIZE build + embedded JS-integration example + selection callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ifcviewer-web/CMakeLists.txt | 37 ++- src/ifcviewer-web/WebViewportHost.h | 2 +- src/ifcviewer-web/main_web.cpp | 77 +++-- src/ifcviewer-web/shell.html | 384 ------------------------ src/ifcviewer-web/web/IfcViewerWeb.html | 246 +++++++++++++++ src/ifcviewer-web/web/embedded.html | 216 +++++++++++++ src/ifcviewer-web/web/ifcviewer.js | 169 +++++++++++ src/ifcviewer-web/web/index.html | 42 +++ src/ifcviewer/CameraMath.h | 9 +- src/ifcviewer/ViewportCore.cpp | 25 +- 10 files changed, 783 insertions(+), 424 deletions(-) delete mode 100644 src/ifcviewer-web/shell.html create mode 100644 src/ifcviewer-web/web/IfcViewerWeb.html create mode 100644 src/ifcviewer-web/web/embedded.html create mode 100644 src/ifcviewer-web/web/ifcviewer.js create mode 100644 src/ifcviewer-web/web/index.html diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index bbd7d70624..1299979f07 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -89,26 +89,32 @@ target_link_options(IfcViewerWeb PRIVATE # also instrument every function reachable from emscripten_sleep, # adding ~30% to wasm size for no win here. # - # EXIT_RUNTIME=0 + Module.noExitRuntime=true (set in shell.html) + # EXIT_RUNTIME=0 + Module.noExitRuntime=true (set in the host page (web/ifcviewer.js)) # keeps wasm alive after main() returns so Dawn-web's # RequestAdapter/RequestDevice promise callbacks land. The # alternative — calling emscripten_set_main_loop_arg early in # main() to set noExitRuntime as a side effect — registers a RAF # that starves the device promise (observed: ~10s delay in Firefox). "-sEXIT_RUNTIME=0" + # Emit a reusable module factory (IfcViewerWeb.js) instead of a baked page, + # so multiple static example pages can load the same wasm. Each page does + # createIfcViewer({ canvas, ... }).then(Module => …) + # (see web/ifcviewer.js, which wraps this into a small integration API). + "-sMODULARIZE=1" + "-sEXPORT_NAME=createIfcViewer" # Expose the C entry points to JS. _raf_tick_c drives the RAF loop - # (shell.html); _load_sidecar_from_blob_c loads a user-picked File via + # (the host page (web/ifcviewer.js)); _load_sidecar_from_blob_c loads a user-picked File via # byte-range Blob.slice reads; _load_sidecar_from_url_c streams a remote # sidecar via HTTP Range; _ifcv_on_range_done / _ifcv_source_ready are the # JS→C completion callbacks for a landed range / a resolved URL size. # EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't - # add them to Module. ccall lets shell.html pass a JS string (the ?model + # add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model # URL) to load_sidecar_from_url_c without manual heap marshalling. "-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c']" - # ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c. + # ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large # sidecar streams by range instead of loading whole). Standard, zero-cost. - "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8']" + "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8','UTF8ToString']" # Streaming + chunked geometry want a heap that can grow as buffers # arrive. 256 MB initial, 2 GB ceiling (matches the wasm32 pointer # cap; --shared64 / MEMORY64 would lift this later if we need it). @@ -124,7 +130,22 @@ target_link_options(IfcViewerWeb PRIVATE # mounts the file at the virtual path the wasm fopen()s. User-picked # files instead stream via Blob.slice byte ranges (load_sidecar_from_blob_c). "--embed-file=${CMAKE_CURRENT_SOURCE_DIR}/sample.ifcview@/sample.ifcview" - # Shell template wraps the JS output in our canvas page. - "--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/shell.html" ) -set_target_properties(IfcViewerWeb PROPERTIES SUFFIX ".html") +# MODULARIZE emits IfcViewerWeb.js (the createIfcViewer factory) + .wasm. +set_target_properties(IfcViewerWeb PROPERTIES SUFFIX ".js") + +# Copy the static example pages + the JS integration helper next to the wasm so +# a plain `python3 -m http.server --directory build-web` serves the whole demo: +# /IfcViewerWeb.html fullscreen example +# /embedded.html embedded viewer + DOM model list / selection (JS API) +# /index.html links to both +set(IFCVIEWERWEB_STATIC + "${CMAKE_CURRENT_SOURCE_DIR}/web/ifcviewer.js" + "${CMAKE_CURRENT_SOURCE_DIR}/web/IfcViewerWeb.html" + "${CMAKE_CURRENT_SOURCE_DIR}/web/embedded.html" + "${CMAKE_CURRENT_SOURCE_DIR}/web/index.html" +) +add_custom_command(TARGET IfcViewerWeb POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${IFCVIEWERWEB_STATIC} "$" + COMMENT "Copying web example pages next to IfcViewerWeb.js") diff --git a/src/ifcviewer-web/WebViewportHost.h b/src/ifcviewer-web/WebViewportHost.h index 44661d9bef..633e93cf98 100644 --- a/src/ifcviewer-web/WebViewportHost.h +++ b/src/ifcviewer-web/WebViewportHost.h @@ -35,7 +35,7 @@ class WebViewportHost final : public ViewportHost { public: // `canvas_selector` is the CSS selector for the host (e.g. - // "#viewer-canvas" — matches shell.html). The string is stored; + // "#viewer-canvas" — matches the host page (web/ifcviewer.js)). The string is stored; // it must outlive the host. explicit WebViewportHost(std::string canvas_selector); diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index afab844e8b..c7404f032d 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -20,9 +20,9 @@ // Web entry point. Wires a WebViewportHost to a ViewportCore, brings up // wgpu via emdawnwebgpu (the spec-compatible WebGPU header set that // shipped with Dawn), loads the embedded sample sidecar, and drives -// render() per requestAnimationFrame from JS (shell.html). +// render() per requestAnimationFrame from JS (the host page (web/ifcviewer.js)). // -// The RAF loop lives in shell.html — NOT here — because any call into +// The RAF loop lives in the host page (web/ifcviewer.js) — NOT here — because any call into // Emscripten's main-loop / RAF helpers (or even raw // requestAnimationFrame via EM_ASM) made from inside Dawn-web's wgpu // promise-resolution chain stalls the device callback. Having JS drive @@ -44,7 +44,7 @@ namespace { -// CSS selector for the host ; must match shell.html + the +// CSS selector for the host ; must match the host page (web/ifcviewer.js) + the // WebViewportHost selector below. constexpr const char* kCanvasSelector = "#viewer-canvas"; @@ -78,6 +78,13 @@ struct AppState { float nav_drag_px = 0.0f; long down_x = 0; long down_y = 0; + // The canvas's top-left in window coords, captured on mousedown. The + // mousemove/mouseup handlers are window-targeted (so a drag can leave the + // canvas), so their coords are window-relative; subtracting this maps them + // back to canvas-relative — the space down_x/down_y and the picker use. + // Zero for a fullscreen canvas pinned at (0,0); nonzero when embedded. + double canvas_origin_x = 0.0; + double canvas_origin_y = 0.0; // ---- Fly (first-person) mode ---- // Shift+F enters (pointer-locks the canvas), Esc exits. While flying, held @@ -118,7 +125,7 @@ int canvasCssHeight() { return (h > 1.0) ? int(h) : 1; } -// Marquee rectangle overlay. The rubber-band is a plain DOM
(shell.html) +// Marquee rectangle overlay. The rubber-band is a plain DOM
(the host page (web/ifcviewer.js)) // positioned in CSS px — the canvas fills the viewport, so canvas-relative // coords are viewport coords. Cheaper + pixel-perfect vs a GPU overlay pass // (which the web lib doesn't have anyway). @@ -136,6 +143,19 @@ void hideMarquee() { EM_ASM({ var m = document.getElementById('marquee'); if (m) m.style.display = 'none'; }); } +// The canvas's top-left in window (client) coords. Window-targeted mouse events +// are window-relative; subtract this to convert them to canvas-relative. +void canvasClientOrigin(double& left, double& top) { + left = EM_ASM_DOUBLE({ + var c = document.getElementById('viewer-canvas'); + return c ? c.getBoundingClientRect().left : 0; + }); + top = EM_ASM_DOUBLE({ + var c = document.getElementById('viewer-canvas'); + return c ? c.getBoundingClientRect().top : 0; + }); +} + NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button, bool shift, bool ctrl, bool alt) { using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; @@ -151,6 +171,9 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); // In fly mode a click exits (matches the desktop app). if (app->fly_mode) { setFlyMode(app, false); return EM_TRUE; } + // Snapshot the canvas origin for this gesture so the window-targeted + // move/up handlers can map their coords back into canvas space. + canvasClientOrigin(app->canvas_origin_x, app->canvas_origin_y); // Section tool: LMB on a plane's gizmo arrow grabs it to slide (logical px). if (app->section_tool_active && e->button == 0) { const int hit = app->core.hitTestSectionGizmo(int(e->targetX), int(e->targetY)); @@ -181,7 +204,8 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { } // Section gizmo drag: slide the grabbed plane along its normal (logical px). if (app->section_dragging) { - app->core.updateSectionDrag(int(e->targetX), int(e->targetY)); + app->core.updateSectionDrag(int(e->targetX - app->canvas_origin_x), + int(e->targetY - app->canvas_origin_y)); return EM_TRUE; } if (!app->nav_active) return EM_FALSE; @@ -192,12 +216,14 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { if (app->nav_kind == NavKind::Orbit) app->core.orbitBy(dx, dy); else if (app->nav_kind == NavKind::Pan) app->core.panBy(dx, dy, canvasCssHeight()); else if (app->nav_kind == NavKind::Select && app->nav_drag_px > kClickDragThresholdPx) { - // Select-button drag → draw the marquee rubber-band (CSS px). - const long x0 = std::min(app->down_x, e->targetX); - const long y0 = std::min(app->down_y, e->targetY); + // Select-button drag → draw the marquee rubber-band (canvas-relative CSS px). + const long mx = long(e->targetX - app->canvas_origin_x); + const long my = long(e->targetY - app->canvas_origin_y); + const long x0 = std::min(app->down_x, mx); + const long y0 = std::min(app->down_y, my); showMarquee(int(x0), int(y0), - int(std::labs(long(e->targetX) - app->down_x)), - int(std::labs(long(e->targetY) - app->down_y))); + int(std::labs(mx - app->down_x)), + int(std::labs(my - app->down_y))); } return EM_TRUE; } @@ -238,11 +264,13 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { if (!no_drag) { // Marquee drag → box-pick the rect (device px) and apply to selection. hideMarquee(); - const long x0 = std::min(app->down_x, e->targetX); - const long y0 = std::min(app->down_y, e->targetY); + const long mx = long(e->targetX - app->canvas_origin_x); + const long my = long(e->targetY - app->canvas_origin_y); + const long x0 = std::min(app->down_x, mx); + const long y0 = std::min(app->down_y, my); const int rx = int(x0 * dpr), ry = int(y0 * dpr); - const int rw = int(std::labs(long(e->targetX) - app->down_x) * dpr); - const int rh = int(std::labs(long(e->targetY) - app->down_y) * dpr); + const int rw = int(std::labs(mx - app->down_x) * dpr); + const int rh = int(std::labs(my - app->down_y) * dpr); app->core.picksInRectAsync(rx, ry, rw, rh, [app, add, remove](std::vector ids) { app->core.applyMarqueeToSelection(ids, add, remove); @@ -253,8 +281,13 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { const int px = int(app->down_x * dpr), py = int(app->down_y * dpr); app->core.pickObjectAtAsync(px, py, [app, add, remove](std::uint32_t id) { app->core.applyPickToSelection(id, add, remove); - // v15 on-demand element metadata fetch: log the picked object's IFC GUID. - if (id != 0) app->core.logSelectedObjectGuidWeb(id); + // Surface the pick to JS: resolve + emit the GUID for a real hit; + // emit an empty selection when a plain click deselects (id 0). + if (id != 0) { + app->core.logSelectedObjectGuidWeb(id); + } else if (!add && !remove) { + EM_ASM({ if (Module.__ifcvOnSelect) Module.__ifcvOnSelect(0, '', -1); }); + } app->host.requestFrame(); }); } @@ -409,7 +442,7 @@ void installInputHandlers(AppState* app) { } // namespace -// Called from shell.html's RAF tick (via Module._raf_tick_c). Exported +// Called from the host page (web/ifcviewer.js)'s RAF tick (via Module._raf_tick_c). Exported // to JS by EXPORTED_FUNCTIONS in CMakeLists.txt; EMSCRIPTEN_KEEPALIVE // also keeps the symbol alive under -O*. extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) { @@ -443,7 +476,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) { } // Stream a sidecar from a registered JS byte-source and APPEND it to the scene -// (federation). shell.html registers the source first — a picked File or a +// (federation). the host page (web/ifcviewer.js) registers the source first — a picked File or a // remote URL, sized up front — into Module.__ifcvSources[source_id], then calls // this. Byte-range: the file is never copied whole into the wasm heap; metadata // is read via ranges and chunks stream per-chunk, so a 500 MB sidecar stays in @@ -454,14 +487,14 @@ extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_source_c(int source_id) { g_app->core.loadSidecarMetadataWeb(source_id, "source"); } -// Drop all loaded models (used by shell.html to replace the embedded sample / +// Drop all loaded models (used by the host page (web/ifcviewer.js) to replace the embedded sample / // a prior federation before loading a fresh set). extern "C" EMSCRIPTEN_KEEPALIVE void clear_scene_c() { if (!g_app || !g_app->ready) return; g_app->core.resetScene(); } -// Viewport-navigation entry points for the shell.html toolbar (buttons that +// Viewport-navigation entry points for the the host page (web/ifcviewer.js) toolbar (buttons that // mirror the keyboard hotkeys). Each schedules a frame. extern "C" EMSCRIPTEN_KEEPALIVE void view_all_c() { if (!g_app || !g_app->ready) return; @@ -518,7 +551,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) { g_app->host.requestFrame(); } -// Streaming progress for the loading bar (shell.html polls these each frame). +// Streaming progress for the loading bar (the host page (web/ifcviewer.js) polls these each frame). // total == 0 while still fetching metadata; resident climbs to total as // geometry chunks arrive. extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_resident_c() { @@ -613,7 +646,7 @@ int main(int /*argc*/, char** /*argv*/) { installInputHandlers(g_app); // Hand the app pointer to the JS-side RAF loop (set up in - // shell.html's onRuntimeInitialized). The loop polls for + // the host page (web/ifcviewer.js)'s onRuntimeInitialized). The loop polls for // Module._app_ptr before invoking _raf_tick_c. EM_ASM({ Module._app_ptr = $0; }, (void*)g_app); }); diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html deleted file mode 100644 index 47b42ccafe..0000000000 --- a/src/ifcviewer-web/shell.html +++ /dev/null @@ -1,384 +0,0 @@ - - - - -IfcViewer (web) - - - - -
-
-
-
-
-
-
-
-
- - - - -
Starting…
- - -{{{ SCRIPT }}} - - diff --git a/src/ifcviewer-web/web/IfcViewerWeb.html b/src/ifcviewer-web/web/IfcViewerWeb.html new file mode 100644 index 0000000000..8ac7799053 --- /dev/null +++ b/src/ifcviewer-web/web/IfcViewerWeb.html @@ -0,0 +1,246 @@ + + + + +IfcViewer (web) — fullscreen + + + + +
+↗ embedded / JS-integration example +
+
+
+
+
+
+
+
+ + + + +
Starting…
+ + + + + + diff --git a/src/ifcviewer-web/web/embedded.html b/src/ifcviewer-web/web/embedded.html new file mode 100644 index 0000000000..9dee6e6454 --- /dev/null +++ b/src/ifcviewer-web/web/embedded.html @@ -0,0 +1,216 @@ + + + + +IfcViewer (web) — embedded / JS integration + + + +
+

IfcOpenShell web viewer — JavaScript integration

+

The viewer is an ordinary page element; the model list and selected GUID are + plain DOM updated from JS.  ↗ fullscreen example

+
+ +
+ +
+
+ +
+
+
Drag to orbit · scroll to zoom · right-click to select (drag right-click to box-select)
+
+ + +
+ + + + + + diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js new file mode 100644 index 0000000000..4f76bf1f8e --- /dev/null +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -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. +// +// +// +// +// +// 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); diff --git a/src/ifcviewer-web/web/index.html b/src/ifcviewer-web/web/index.html new file mode 100644 index 0000000000..c28f8cde09 --- /dev/null +++ b/src/ifcviewer-web/web/index.html @@ -0,0 +1,42 @@ + + + + +IfcOpenShell web viewer — examples + + + + + + diff --git a/src/ifcviewer/CameraMath.h b/src/ifcviewer/CameraMath.h index 33e16ef5d8..b2d282dc47 100644 --- a/src/ifcviewer/CameraMath.h +++ b/src/ifcviewer/CameraMath.h @@ -31,10 +31,13 @@ #include -#include - #include +// pi as float. A plain constant rather than boost::math::constants so this +// header stays dependency-light and compiles under the Emscripten sysroot +// (which has no Boost) — CameraMath is shared by the desktop and web builds. +inline constexpr float kPiF = 3.14159265358979323846f; + inline Eigen::Matrix4f lookAtRH(const Eigen::Vector3f& eye, const Eigen::Vector3f& target, const Eigen::Vector3f& up) { @@ -50,7 +53,7 @@ inline Eigen::Matrix4f lookAtRH(const Eigen::Vector3f& eye, inline Eigen::Matrix4f perspectiveYFovGL(float fovy_deg, float aspect, float near_plane, float far_plane) { - const float fovy_rad = fovy_deg * boost::math::constants::pi() / 180.0f; + const float fovy_rad = fovy_deg * kPiF / 180.0f; const float t = std::tan(fovy_rad * 0.5f); Eigen::Matrix4f m = Eigen::Matrix4f::Zero(); m(0, 0) = 1.0f / (aspect * t); diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 99c3000a0d..778a724cc0 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -31,7 +31,6 @@ #include "InstanceCompose.h" #include "Log.h" -#include #include #include @@ -47,7 +46,7 @@ namespace { // updateCamera convention so framing aligns between backends. Eigen::Vector3f orbitEye(const float target[3], float dist, float yaw_deg, float pitch_deg) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; const float yaw = yaw_deg * kDeg2Rad; const float pit = pitch_deg * kDeg2Rad; const float cp = std::cos(pit), sp = std::sin(pit); @@ -178,7 +177,7 @@ void ViewportCore::buildViewProj(Eigen::Matrix4f& view_out, : 1.0f; Eigen::Matrix4f p; if (projection_ortho_) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; const float half_h = camera_distance_ * std::tan(camera_fov_y_deg_ * 0.5f * kDeg2Rad); const float half_w = half_h * aspect; @@ -387,7 +386,7 @@ void ViewportCore::composeInstanceFromPlacement(InstanceInfo& inst, void ViewportCore::frameAabb(const float mn[3], const float mx[3], float padding) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; const float cx = 0.5f * (mn[0] + mx[0]); const float cy = 0.5f * (mn[1] + mx[1]); const float cz = 0.5f * (mn[2] + mx[2]); @@ -513,7 +512,7 @@ void ViewportCore::orbitBy(float dx_px, float dy_px) { } void ViewportCore::panBy(float dx_px, float dy_px, int viewport_height_px) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; // Pan in the camera's screen-space plane. Within 1° of straight // up/down the world-Z up-reference degenerates (cross with forward @@ -3732,6 +3731,20 @@ void ViewportCore::logSelectedObjectGuidWeb(std::uint32_t object_id) { ? m.string_table.substr(e.guid_offset, e.guid_length) : std::string("(none)"); Log::info() << "pick: object " << object_id << " GUID " << guid; + // Load-order index of the object's model (sorted by session id — the + // same order as streamingModelProgress and the JS model list); -1 if + // not found. Lets host pages show which model the pick belongs to. + std::vector model_ids; + model_ids.reserve(models_gpu_.size()); + for (const auto& [id, mm] : models_gpu_) model_ids.push_back(id); + std::sort(model_ids.begin(), model_ids.end()); + const auto pos = std::find(model_ids.begin(), model_ids.end(), session_model_id); + const int model_index = (pos != model_ids.end()) ? int(pos - model_ids.begin()) : -1; + // Surface the selection to JS so host pages can react (e.g. show the + // GUID + model). Fires Module.__ifcvOnSelect(object_id, guid, modelIndex). + EM_ASM({ + if (Module.__ifcvOnSelect) Module.__ifcvOnSelect($0, UTF8ToString($1), $2); + }, object_id, guid.c_str(), model_index); return; } Log::info() << "pick: object " << object_id << " not in element table"; @@ -6121,7 +6134,7 @@ namespace { // degrees → radians. Inline-only, used inside render() for the // focal-length derivation. constexpr float degreesToRadians(float deg) { - return deg * boost::math::constants::pi() / 180.0f; + return deg * kPiF / 180.0f; } // Format a float with N decimals into the running Log line. Used to