mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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} "$<TARGET_FILE_DIR:IfcViewerWeb>"
|
||||
COMMENT "Copying web example pages next to IfcViewerWeb.js")
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
class WebViewportHost final : public ViewportHost {
|
||||
public:
|
||||
// `canvas_selector` is the CSS selector for the host <canvas> (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);
|
||||
|
||||
|
||||
@@ -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 <canvas>; must match shell.html + the
|
||||
// CSS selector for the host <canvas>; 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 <div> (shell.html)
|
||||
// Marquee rectangle overlay. The rubber-band is a plain DOM <div> (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<AppState*>(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<long>(app->down_x, e->targetX);
|
||||
const long y0 = std::min<long>(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<long>(app->down_x, mx);
|
||||
const long y0 = std::min<long>(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<long>(app->down_x, e->targetX);
|
||||
const long y0 = std::min<long>(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<long>(app->down_x, mx);
|
||||
const long y0 = std::min<long>(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<std::uint32_t> 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);
|
||||
});
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
<!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; }
|
||||
/* 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 (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 button.active { background: #2b6cb0; color: #fff; }
|
||||
#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; }
|
||||
/* Combined bar over the FULL model content: dark track = not needed for this
|
||||
view, dim = needed-but-not-loaded, bright = loaded. So the bright fill vs
|
||||
the dim span shows "loaded / needed", and the dim span vs the whole track
|
||||
shows "needed / total". */
|
||||
#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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="viewer-canvas" width="1280" height="800"></canvas>
|
||||
<div id="marquee"></div>
|
||||
<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>
|
||||
// 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();
|
||||
if (Module._fly_is_active_c) {
|
||||
var fb = document.getElementById('fly-btn');
|
||||
if (fb) fb.classList.toggle('active', !!Module._fly_is_active_c());
|
||||
}
|
||||
if (Module._xray_is_active_c) {
|
||||
var xb = document.getElementById('xray-btn');
|
||||
if (xb) xb.classList.toggle('active', !!Module._xray_is_active_c());
|
||||
}
|
||||
if (Module._section_is_active_c) {
|
||||
var sb = document.getElementById('section-btn');
|
||||
if (sb) sb.classList.toggle('active', !!Module._section_is_active_c());
|
||||
}
|
||||
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 neededEl = document.getElementById('progress-needed');
|
||||
var loadedEl = document.getElementById('progress-loaded');
|
||||
var loadActive = false;
|
||||
var expectedModels = 1;
|
||||
var caughtUpAt = 0;
|
||||
// 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;
|
||||
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); }
|
||||
// Combined progress over the whole federation, honouring contribution culling:
|
||||
// loaded / needed = how done THIS view is
|
||||
// needed / total = how much of the whole model this view even requires
|
||||
// Auto-shows whenever there's work (initial load OR navigation revealing new
|
||||
// chunks) and fades shortly after the current view is fully loaded.
|
||||
window.updateLoadProgress = function() {
|
||||
if (!Module._ifcv_bytes_total_c) return;
|
||||
var total = Module._ifcv_bytes_total_c();
|
||||
var needed = Module._ifcv_bytes_needed_c();
|
||||
var loaded = Module._ifcv_bytes_loaded_c();
|
||||
var mc = Module._ifcv_model_count_c ? Module._ifcv_model_count_c() : 0;
|
||||
var dlMB = (Module.__ifcvBytesLoaded || 0) / 1e6;
|
||||
// No geometry chunks yet = still fetching the per-model "overhead" (mesh +
|
||||
// instance metadata) needed before we can even tell which chunks are visible.
|
||||
var overhead = total === 0;
|
||||
var streaming = needed > loaded + 1; // geometry still to fetch for this view
|
||||
if (overhead || streaming) { loadActive = true; caughtUpAt = 0; }
|
||||
if (!loadActive) return;
|
||||
// Re-assert visibility every active frame so the bar REAPPEARS when
|
||||
// navigation reveals new chunks after a previous hide (fix: was only set
|
||||
// in beginLoadProgress, so it stayed hidden).
|
||||
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;
|
||||
}
|
||||
|
||||
// Geometry phase: bar spans the whole model; dim = needed for this view,
|
||||
// bright = loaded. "loaded / needed" = this view's progress; "needed / total"
|
||||
// = how much of the model this view requires.
|
||||
neededEl.style.width = (100 * needed / total) + '%';
|
||||
loadedEl.style.width = (100 * loaded / total) + '%';
|
||||
fillEl.style.width = (needed > 0 ? Math.round(100 * loaded / needed) : 100) + '%';
|
||||
var pctNeeded = Math.round(100 * needed / total);
|
||||
var more = (mc < expectedModels) ? ' · ' + mc + '/' + expectedModels + ' models' : '';
|
||||
if (streaming) {
|
||||
summaryEl.textContent = 'Loading ' + fmtMB(loaded) + ' / ' + fmtMB(needed) +
|
||||
' MB for this view · ' + pctNeeded + '% of ' + fmtMB(total) + ' MB total' + more;
|
||||
} else {
|
||||
summaryEl.textContent = (pctNeeded >= 99 ? 'Loaded ' : 'View loaded — ') +
|
||||
fmtMB(loaded) + ' MB · ' + pctNeeded + '% of ' + fmtMB(total) + ' MB total' + more;
|
||||
if (!caughtUpAt) caughtUpAt = performance.now();
|
||||
if (performance.now() - caughtUpAt > 1500) endLoadProgress();
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
// RMB is the select/marquee button in the Web nav preset, so suppress the
|
||||
// browser context menu over the canvas. (Firefox forces its native menu on
|
||||
// Shift+RightClick regardless — a browser escape hatch pages can't override.)
|
||||
var viewerCanvas = document.getElementById('viewer-canvas');
|
||||
if (viewerCanvas) {
|
||||
viewerCanvas.addEventListener('contextmenu', function(ev) { ev.preventDefault(); });
|
||||
}
|
||||
|
||||
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 (act === 'fly') Module._toggle_fly_c();
|
||||
else if (act === 'hide') Module._hide_selected_c();
|
||||
else if (act === 'isolate') Module._isolate_selected_c();
|
||||
else if (act === 'showall') Module._show_all_c();
|
||||
else if (act === 'xray') Module._toggle_xray_c();
|
||||
else if (act === 'section') Module._toggle_section_c();
|
||||
else if (act === 'clearcut') Module._clear_section_c();
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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. <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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -31,10 +31,13 @@
|
||||
|
||||
#include <Eigen/Dense>
|
||||
|
||||
#include <boost/math/constants/constants.hpp>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
// 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<float>() / 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);
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
#include "InstanceCompose.h"
|
||||
#include "Log.h"
|
||||
|
||||
#include <boost/math/constants/constants.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -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<float>() / 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<float>() / 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<float>() / 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<float>() / 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<std::uint32_t> 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<float>() / 180.0f;
|
||||
return deg * kPiF / 180.0f;
|
||||
}
|
||||
|
||||
// Format a float with N decimals into the running Log line. Used to
|
||||
|
||||
Reference in New Issue
Block a user