/******************************************************************************** * * * This file is part of IfcOpenShell. * * * * IfcOpenShell is free software: you can redistribute it and/or modify * * it under the terms of the Lesser GNU General Public License as published by * * the Free Software Foundation, either version 3.0 of the License, or * * (at your option) any later version. * * * * IfcOpenShell is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * Lesser GNU General Public License for more details. * * * * You should have received a copy of the Lesser GNU General Public License * * along with this program. If not, see . * * * ********************************************************************************/ // 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 (the host page (web/ifcviewer.js)). // // 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 // the tick keeps the wasm init path callback-only. #include "ViewportCore.h" #include "WebViewportHost.h" #include "Log.h" #include #include #include #include #include #include #include #include #include #include #include namespace { // CSS selector for the host ; must match the host page (web/ifcviewer.js) + the // WebViewportHost selector below. constexpr const char* kCanvasSelector = "#viewer-canvas"; // What a mouse drag drives, decided against the active nav preset's bindings. enum class NavKind { None, Orbit, Pan, Select }; struct AppState { WebViewportHost host{ kCanvasSelector }; ViewportCore core{ &host }; int last_w = 0; int last_h = 0; // Set true by the init callback once the device + pipelines are up. // raf_tick_c skips render() until then; before that point the wgpu // state pointers inside core are still null and any draw would crash. bool ready = false; // ---- Mouse navigation state ---- // A drag is armed on mousedown and released on mouseup. What the press // drives (orbit / pan / select) is decided against the active nav preset's // bindings (ViewportCore::navBindings), so any preset works on web too. bool nav_active = false; NavKind nav_kind = NavKind::None; // Section-cut tool: while active, a select-button click drops a clip plane // at the picked surface (K toggles, Shift+K clears — matches desktop). bool section_tool_active = false; // True while dragging a section-plane gizmo (LMB down on the arrow → slide). bool section_dragging = false; // Accumulated |movement| since mousedown, in CSS px. A select-button release // under the click threshold (no real drag) is treated as a pick; a drag will // become a marquee. Captures the down position (canvas-relative CSS px). 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 // W/A/S/D/Q/E + Shift are integrated each frame via ViewportCore::flyMove, // and pointer-lock mouse deltas drive flyLook. dt from fly_last_ms. bool fly_mode = false; // True once the browser actually granted pointer lock for this fly session. // Distinguishes "lock lost" (Esc/click-out → exit fly) from "lock denied on // entry" (headless / permission) where fly stays keyboard-drivable. bool fly_locked = false; bool key_w_pressed = false; bool key_a_pressed = false; bool key_s_pressed = false; bool key_d_pressed = false; bool key_q_pressed = false; bool key_e_pressed = false; bool key_shift_pressed = false; double fly_last_ms = 0.0; }; // Click vs drag threshold (CSS px). Below this total travel a left release is // a pick, above it the gesture was an orbit. constexpr float kClickDragThresholdPx = 4.0f; // One global so the JS-side RAF loop can recover state through a // pointer round-trip (set into Module._app_ptr from on_complete). AppState* g_app = nullptr; // Defined below (with the fly helpers); onMouseDown needs it to exit fly on click. void setFlyMode(AppState* app, bool on); // Logical (CSS-pixel) height of the canvas. Mouse movementX/Y deltas are // in CSS pixels, so pan's world-units-per-pixel must use CSS-pixel height // too (not the DPR-scaled framebuffer height). int canvasCssHeight() { double w = 0.0, h = 0.0; emscripten_get_element_css_size(kCanvasSelector, &w, &h); return (h > 1.0) ? int(h) : 1; } // 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). void showMarquee(int x, int y, int w, int h) { EM_ASM({ var m = document.getElementById('marquee'); if (m) { m.style.display = 'block'; m.style.left = $0 + 'px'; m.style.top = $1 + 'px'; m.style.width = $2 + 'px'; m.style.height = $3 + 'px'; } }, x, y, w, h); } 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; }); } // Tell the page the selection changed; it pulls the new id set back through // ifcv_get_selection_c. Every wasm-side mutation (single pick, marquee, hide- // selected) fires this, so a host UI tracking multi-selection never has to poll. // The JS API layer (web/ifcviewer.js) also fires it after its own programmatic // mutations, so listeners see one event stream regardless of the source. void notifySelectionChanged() { EM_ASM({ if (Module.__ifcvOnSelectionChange) Module.__ifcvOnSelectionChange(); }); } // The (pointer, count) id array the JS side marshals into the wasm heap. A null // pointer with a zero count is a legitimate empty list — "clear the selection" // arrives that way — so it must not be turned into pointer arithmetic on null. std::vector idsFrom(const std::uint32_t* ids, int n) { if (!ids || n <= 0) return {}; return std::vector(ids, ids + n); } // The reading half of the same convention: write `ids` ascending into `out` // (at most `max` of them) and return the TOTAL, so a caller that passed a // too-small buffer — or none at all — knows what to allocate and can ask again. int fillIdsAscending(const std::unordered_set& ids, std::uint32_t* out, int max) { std::vector sorted(ids.begin(), ids.end()); std::sort(sorted.begin(), sorted.end()); const int n = std::min(int(sorted.size()), std::max(0, max)); if (out && n > 0) std::copy_n(sorted.begin(), n, out); return int(sorted.size()); } // Quote `s` as a JSON string literal. IFC names come straight from the model // and can hold quotes, backslashes and control characters; UTF-8 continuation // bytes are already legal JSON and pass through untouched. std::string jsonString(const std::string& s) { std::string out = "\""; for (unsigned char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\b': out += "\\b"; break; case '\f': out += "\\f"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (c < 0x20) { char esc[7]; std::snprintf(esc, sizeof(esc), "\\u%04x", c); out += esc; } else { out += char(c); } } } return out + '"'; } NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button, bool shift, bool ctrl, bool alt) { using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; const MB btn = (em_button == 0) ? MB::Left : (em_button == 1) ? MB::Middle : MB::Right; const M mod = shift ? M::Shift : ctrl ? M::Ctrl : alt ? M::Alt : M::Plain; if (btn == b.orbit && mod == b.orbit_mod) return NavKind::Orbit; if (btn == b.pan && mod == b.pan_mod) return NavKind::Pan; if (btn == b.select) return NavKind::Select; // Shift/Ctrl = add/remove return NavKind::None; } 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)); if (hit >= 0 && app->core.beginSectionDrag(hit, int(e->targetX), int(e->targetY))) { app->section_dragging = true; return EM_TRUE; // claim the press — don't orbit } } const NavKind kind = classifyPress(app->core.navBindings(), e->button, e->shiftKey, e->ctrlKey, e->altKey); if (kind != NavKind::None) { app->nav_active = true; app->nav_kind = kind; app->nav_drag_px = 0.0f; app->down_x = e->targetX; // canvas-relative CSS px app->down_y = e->targetY; } return EM_TRUE; } EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); if (!app->ready) return EM_FALSE; // Fly mode: pointer-locked mouse deltas turn the camera in place. if (app->fly_mode) { app->core.flyLook(float(e->movementX), float(e->movementY)); return EM_TRUE; } // Section gizmo drag: slide the grabbed plane along its normal (logical px). if (app->section_dragging) { 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; const float dx = float(e->movementX); const float dy = float(e->movementY); app->nav_drag_px += std::abs(dx) + std::abs(dy); 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 (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(mx - app->down_x)), int(std::labs(my - app->down_y))); } return EM_TRUE; } EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); const bool was_active = app->nav_active; const NavKind kind = app->nav_kind; app->nav_active = false; app->nav_kind = NavKind::None; // End a section-gizmo drag (took over the press; no pick/orbit on release). if (app->section_dragging) { app->core.endSectionDrag(); app->section_dragging = false; return EM_TRUE; } if (!was_active || !app->ready) return EM_TRUE; const double dpr = emscripten_get_device_pixel_ratio(); const bool no_drag = app->nav_drag_px <= kClickDragThresholdPx; // Section tool claims a LEFT-button click ("click a surface to cut"); LMB // drag still orbits. Takes priority over nav while the tool is active. if (app->section_tool_active && e->button == 0 && no_drag) { const int px = int(app->down_x * dpr), py = int(app->down_y * dpr); app->core.pickSurfaceAtAsync(px, py, [app](ViewportCore::SurfaceHit hit) { if (hit.found) // pad past the AABB so the cut reads as a cap app->core.addSectionPlaneAtSurface(hit.world_pos, hit.world_normal, hit.aabb_radius * 1.5f); app->host.requestFrame(); }); return EM_TRUE; } if (kind == NavKind::Select) { const bool add = e->shiftKey, remove = e->ctrlKey; if (!no_drag) { // Marquee drag → box-pick the rect (device px) and apply to selection. hideMarquee(); 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(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); notifySelectionChanged(); app->host.requestFrame(); }); } else { // Single pick under the cursor (Shift add, Ctrl remove, plain replace). 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); notifySelectionChanged(); // 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(); }); } } return EM_TRUE; } EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) { auto* app = static_cast(user); if (!app->ready) return EM_FALSE; // Normalise to "notches" like the desktop wheel (one notch ≈ 120 // angle-units ≈ 100 px of deltaY). Line/page modes are scaled to a // comparable pixel magnitude. Negate so wheel-up (deltaY < 0) zooms in. double dy = e->deltaY; if (e->deltaMode == DOM_DELTA_LINE) dy *= 16.0; else if (e->deltaMode == DOM_DELTA_PAGE) dy *= 800.0; // In fly mode the wheel tunes move speed (Blender convention), not zoom. if (app->fly_mode) { app->core.flyAdjustSpeed(-float(dy) / 100.0f); return EM_TRUE; } app->core.dollyBy(-float(dy) / 100.0f); return EM_TRUE; // consume so the page doesn't scroll } // Track a held fly movement key (W/A/S/D/Q/E/Shift). Returns true if `code` was // one. Physical `.code` so it's keymap-independent. bool setFlyKey(AppState* app, const char* code, bool down) { if (!std::strcmp(code, "KeyW")) app->key_w_pressed = down; else if (!std::strcmp(code, "KeyA")) app->key_a_pressed = down; else if (!std::strcmp(code, "KeyS")) app->key_s_pressed = down; else if (!std::strcmp(code, "KeyD")) app->key_d_pressed = down; else if (!std::strcmp(code, "KeyQ")) app->key_q_pressed = down; else if (!std::strcmp(code, "KeyE")) app->key_e_pressed = down; else if (!std::strcmp(code, "ShiftLeft") || !std::strcmp(code, "ShiftRight")) app->key_shift_pressed = down; else return false; return true; } // Enter/leave fly mode: pointer-lock the canvas for mouse-look on enter, release // on exit. Shared camera math is ViewportCore::flyMove/flyLook. void setFlyMode(AppState* app, bool on) { if (app->fly_mode == on) return; app->fly_mode = on; if (on) { app->fly_last_ms = emscripten_get_now(); emscripten_request_pointerlock(kCanvasSelector, EM_TRUE); Log::info() << "[fly] on — WASD/QE move, mouse looks, Shift boosts, wheel = speed, Esc exits"; } else { app->key_w_pressed = app->key_a_pressed = app->key_s_pressed = false; app->key_d_pressed = app->key_q_pressed = app->key_e_pressed = false; app->key_shift_pressed = false; app->fly_locked = false; emscripten_exit_pointerlock(); Log::info() << "[fly] off"; } app->host.requestFrame(); } // Viewport hotkeys, matching the desktop bindings (ViewportWindow): // Home view all · F zoom to selected · P ortho/persp · X/Y/Z (+Shift) views // Shift+F enter fly · Esc exit fly · while flying: W/A/S/D/Q/E held + Shift. EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) { auto* app = static_cast(user); if (!app->ready) return EM_FALSE; const char* code = e->code; const bool shift = e->shiftKey; // Shift+F toggles fly; Esc leaves it. if (!std::strcmp(code, "KeyF") && shift && !e->repeat) { setFlyMode(app, !app->fly_mode); return EM_TRUE; } if (!std::strcmp(code, "Escape") && app->fly_mode) { setFlyMode(app, false); return EM_TRUE; } // While flying, WASDQE/Shift are held-movement keys, not hotkeys. if (app->fly_mode) { if (setFlyKey(app, code, true)) return EM_TRUE; return EM_FALSE; } if (e->repeat) return EM_FALSE; const bool alt = e->altKey; // Visibility + X-ray, matching desktop: H hide selected · Shift+H isolate · // Alt+H show all · Alt+X x-ray. if (!std::strcmp(code, "KeyH")) { if (alt) app->core.showAll(); else if (shift) app->core.isolateSelected(); else app->core.hideSelected(); return EM_TRUE; } if (!std::strcmp(code, "KeyX") && alt) { app->core.toggleXray(); return EM_TRUE; } // Section tool: K toggles drop-a-plane mode, Shift+K clears all cuts. if (!std::strcmp(code, "KeyK")) { if (shift) app->core.clearSectionPlanes(); else { app->section_tool_active = !app->section_tool_active; Log::info() << "[section] tool " << (app->section_tool_active ? "active — click a surface" : "off"); } app->host.requestFrame(); return EM_TRUE; } // Del/Backspace removes the most recent cut while the tool is active. if (app->section_tool_active && (!std::strcmp(code, "Delete") || !std::strcmp(code, "Backspace"))) { const int n = app->core.sectionPlaneCount(); if (n > 0) app->core.removeSectionPlane(n - 1); app->host.requestFrame(); return EM_TRUE; } using SV = ViewportCore::StandardView; if (!std::strcmp(code, "Home")) app->core.viewAll(); else if (!std::strcmp(code, "KeyF") && !shift) app->core.frameSelection(); else if (!std::strcmp(code, "KeyP") && !shift) app->core.toggleProjection(); else if (!std::strcmp(code, "KeyX")) app->core.setStandardView(shift ? SV::Back : SV::Front); else if (!std::strcmp(code, "KeyY")) app->core.setStandardView(shift ? SV::Left : SV::Right); else if (!std::strcmp(code, "KeyZ")) app->core.setStandardView(shift ? SV::Bottom : SV::Top); else return EM_FALSE; // let every other key through to the browser app->host.requestFrame(); return EM_TRUE; } EM_BOOL onKeyUp(int, const EmscriptenKeyboardEvent* e, void* user) { auto* app = static_cast(user); return setFlyKey(app, e->code, false) ? EM_TRUE : EM_FALSE; } // Pointer-lock is the fly-look mechanism. When it's LOST (the browser eats the // first Esc to release it, or the user clicks out) leave fly mode — this is what // makes a single Esc exit cleanly. `fly_locked` guards against a denied lock on // entry (headless / permission) firing this and insta-exiting: we only treat a // loss as an exit if we'd actually acquired the lock. EM_BOOL onPointerLockChange(int, const EmscriptenPointerlockChangeEvent* e, void* user) { auto* app = static_cast(user); if (e->isActive) { app->fly_locked = true; } else if (app->fly_locked && app->fly_mode) { setFlyMode(app, false); } return EM_TRUE; } // Register pointer + wheel handlers once the app is live. mousedown binds // to the canvas; mousemove/up bind to the window so a drag keeps tracking // when the pointer leaves the canvas. A JS-side contextmenu suppressor // lets right-drag pan without popping the browser menu. void installInputHandlers(AppState* app) { emscripten_set_mousedown_callback(kCanvasSelector, app, EM_FALSE, onMouseDown); emscripten_set_mousemove_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onMouseMove); emscripten_set_mouseup_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onMouseUp); emscripten_set_wheel_callback(kCanvasSelector, app, EM_FALSE, onWheel); emscripten_set_keydown_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onKeyDown); emscripten_set_keyup_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onKeyUp); emscripten_set_pointerlockchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, app, EM_FALSE, onPointerLockChange); EM_ASM({ var c = document.querySelector(UTF8ToString($0)); if (c) c.addEventListener('contextmenu', function(ev) { ev.preventDefault(); }); }, kCanvasSelector); } } // namespace // 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) { auto* app = static_cast(user); if (!app->ready) return; int w = 0, h = 0; app->host.framebufferSize(w, h); if (w != app->last_w || h != app->last_h) { app->core.configureSurface(w, h); app->last_w = w; app->last_h = h; } // Fly mode: integrate held-key movement each frame (dt from wall clock). // flyMove schedules a frame when it actually moves, so a still fly camera // costs nothing. if (app->fly_mode) { const double now = emscripten_get_now(); const float dt = float((now - app->fly_last_ms) / 1000.0); app->fly_last_ms = now; app->core.flyMove(app->key_w_pressed, app->key_s_pressed, app->key_d_pressed, app->key_a_pressed, app->key_e_pressed, app->key_q_pressed, app->key_shift_pressed, dt); } if (app->host.consumeFrameRequest()) { app->core.render(); } } // Stream a sidecar from a registered JS byte-source and APPEND it to the scene // (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 // the File / on the server. Asynchronous; the model frames itself from the JS // completion callback. Call clear_scene_c first to replace instead of append. extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_source_c(int source_id) { if (!g_app || !g_app->ready) return; g_app->core.loadSidecarMetadataWeb(source_id, "source"); } // 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 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; g_app->core.viewAll(); g_app->host.requestFrame(); } extern "C" EMSCRIPTEN_KEEPALIVE void frame_selection_c() { if (!g_app || !g_app->ready) return; g_app->core.frameSelection(); g_app->host.requestFrame(); } extern "C" EMSCRIPTEN_KEEPALIVE void toggle_projection_c() { if (!g_app || !g_app->ready) return; g_app->core.toggleProjection(); g_app->host.requestFrame(); } extern "C" EMSCRIPTEN_KEEPALIVE int projection_is_ortho_c() { return (g_app && g_app->ready && g_app->core.projectionOrtho()) ? 1 : 0; } // Toggle fly (first-person) mode from the toolbar. The button click is a user // gesture, so the pointer-lock request inside succeeds. extern "C" EMSCRIPTEN_KEEPALIVE void toggle_fly_c() { if (g_app && g_app->ready) setFlyMode(g_app, !g_app->fly_mode); } extern "C" EMSCRIPTEN_KEEPALIVE int fly_is_active_c() { return (g_app && g_app->ready && g_app->fly_mode) ? 1 : 0; } // Visibility + X-ray, for the toolbar (same ops as the H/Shift+H/Alt+H/Alt+X keys). extern "C" EMSCRIPTEN_KEEPALIVE void hide_selected_c() { if (!g_app || !g_app->ready) return; g_app->core.hideSelected(); // hiding deselects notifySelectionChanged(); } extern "C" EMSCRIPTEN_KEEPALIVE void isolate_selected_c() { if (g_app && g_app->ready) g_app->core.isolateSelected(); } extern "C" EMSCRIPTEN_KEEPALIVE void show_all_c() { if (g_app && g_app->ready) g_app->core.showAll(); } extern "C" EMSCRIPTEN_KEEPALIVE void hide_all_c() { if (g_app && g_app->ready) g_app->core.hideAll(); } extern "C" EMSCRIPTEN_KEEPALIVE void toggle_xray_c() { if (g_app && g_app->ready) g_app->core.toggleXray(); } extern "C" EMSCRIPTEN_KEEPALIVE int xray_is_active_c() { return (g_app && g_app->ready && g_app->core.xrayActive()) ? 1 : 0; } // =========================================================================== // Scripting API (web/ifcviewer.js wraps these into the IfcViewer object) // =========================================================================== // // Arrays cross the boundary as (pointer, count) into the wasm heap; JS // allocates with _malloc, fills HEAPU32, calls, frees. The getters follow the // "ask twice" convention: they always return the TOTAL count and fill at most // `max` entries, so a caller can size a buffer with (null, 0) and call again. // Ids are object_ids — globally unique across federated models. The JS layer // maps IFC GUIDs onto them from the element table (ifcv_request_objects_c). // Camera state, as 9 floats: target xyz, distance, yaw°, pitch°, eye xyz. Eye // comes from the core rather than being re-derived in JS, so the orbit // convention has exactly one definition. extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_get_camera_c(float* out) { if (!g_app || !g_app->ready || !out) return; const ViewportCore::CameraState s = g_app->core.cameraState(); const Eigen::Vector3f eye = g_app->core.cameraEye(); out[0] = s.target.x(); out[1] = s.target.y(); out[2] = s.target.z(); out[3] = s.distance; out[4] = s.yaw; out[5] = s.pitch; out[6] = eye.x(); out[7] = eye.y(); out[8] = eye.z(); } extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_camera_c(float tx, float ty, float tz, float dist, float yaw, float pitch) { if (!g_app || !g_app->ready) return; g_app->core.setCamera(tx, ty, tz, dist, yaw, pitch); } // toggleProjection is the only projection mutator in the core; drive it to the // requested state so JS doesn't have to read-then-toggle. extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_ortho_c(int on) { if (!g_app || !g_app->ready) return; if (bool(on) != g_app->core.projectionOrtho()) g_app->core.toggleProjection(); } // Selection. extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_selection_c(std::uint32_t* out, int max) { if (!g_app || !g_app->ready) return 0; return fillIdsAscending(g_app->core.selection().selectionIds(), out, max); } extern "C" EMSCRIPTEN_KEEPALIVE std::uint32_t ifcv_get_active_object_c() { return (g_app && g_app->ready) ? g_app->core.selection().activeId() : 0u; } // mode: 0 replace (n == 0 clears), 1 add, 2 remove. applyMarqueeToSelection is // the core's selection primitive and already means exactly this. extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_apply_selection_c(const std::uint32_t* ids, int n, int mode) { if (!g_app || !g_app->ready) return; g_app->core.applyMarqueeToSelection(idsFrom(ids, n), mode == 1, mode == 2); } // Per-object visibility. show_all_c / hide_all_c above cover the bulk cases. extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_visible_c(const std::uint32_t* ids, int n, int visible) { if (!g_app || !g_app->ready) return; g_app->core.setObjectsVisible(idsFrom(ids, n), visible != 0); } extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_hidden_c(std::uint32_t* out, int max) { if (!g_app || !g_app->ready) return 0; return fillIdsAscending(g_app->core.hiddenIds(), out, max); } // Colour override. rgba8 is packed 0xAABBGGRR; 0 restores the baked colour. extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_color_c(const std::uint32_t* ids, int n, std::uint32_t rgba8) { if (!g_app || !g_app->ready) return; g_app->core.setObjectsColor(idsFrom(ids, n), rgba8); } extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_clear_colors_c() { if (g_app && g_app->ready) g_app->core.clearObjectColors(); } // Every object in the scene, as JSON. Asynchronous: the element tables are // fetched lazily per model on web (first paint must not wait on them), so this // makes sure they are all resident and only then hands the page its array via // Module.__ifcvOnObjects(token, json). `token` correlates the reply with the // Promise the JS layer is holding. extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_request_objects_c(int token) { if (!g_app || !g_app->ready) { EM_ASM({ if (Module.__ifcvOnObjects) Module.__ifcvOnObjects($0, '[]'); }, token); return; } g_app->core.loadAllElementMetadataWeb([token](bool) { // Partial failures are not fatal: a model whose element block failed to // fetch simply contributes no rows, and the rest still resolve. std::string json = "["; bool first = true; for (const ViewportCore::ElementRef& e : g_app->core.elements()) { if (!first) json += ','; first = false; json += "{\"objectId\":" + std::to_string(e.object_id) + ",\"model\":" + std::to_string(e.model_index) + ",\"guid\":" + jsonString(e.guid) + ",\"name\":" + jsonString(e.name) + ",\"type\":" + jsonString(e.type) + '}'; } json += ']'; EM_ASM({ if (Module.__ifcvOnObjects) Module.__ifcvOnObjects($0, UTF8ToString($1)); }, token, json.c_str()); }); } // Section-cut tool: toggle the drop-a-plane mode, clear all planes, query state. extern "C" EMSCRIPTEN_KEEPALIVE void toggle_section_c() { if (!g_app || !g_app->ready) return; g_app->section_tool_active = !g_app->section_tool_active; Log::info() << "[section] tool " << (g_app->section_tool_active ? "active — click a surface to cut" : "off"); g_app->host.requestFrame(); } extern "C" EMSCRIPTEN_KEEPALIVE void clear_section_c() { if (g_app && g_app->ready) { g_app->core.clearSectionPlanes(); g_app->host.requestFrame(); } } extern "C" EMSCRIPTEN_KEEPALIVE int section_is_active_c() { return (g_app && g_app->ready && g_app->section_tool_active) ? 1 : 0; } // id: 0 Front, 1 Back, 2 Left, 3 Right, 4 Top, 5 Bottom. extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) { if (!g_app || !g_app->ready || id < 0 || id > 5) return; using SV = ViewportCore::StandardView; static const SV map[6] = { SV::Front, SV::Back, SV::Left, SV::Right, SV::Top, SV::Bottom }; g_app->core.setStandardView(map[id]); g_app->host.requestFrame(); } // 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() { if (!g_app) return 0; int resident_chunks = 0, total_chunks = 0; g_app->core.streamingProgress(resident_chunks, total_chunks); return resident_chunks; } extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_total_c() { if (!g_app) return 0; int resident_chunks = 0, total_chunks = 0; g_app->core.streamingProgress(resident_chunks, total_chunks); return total_chunks; } // Per-model progress for the federation loading panel: how many models are in // the scene, and the idx-th model's resident/total chunks (idx ordered by load). extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_count_c() { return g_app ? g_app->core.streamingModelCount() : 0; } extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_resident_c(int idx) { if (!g_app) return 0; int resident_chunks = 0, total_chunks = 0; g_app->core.streamingModelProgress(idx, resident_chunks, total_chunks); return resident_chunks; } extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_total_c(int idx) { if (!g_app) return 0; int resident_chunks = 0, total_chunks = 0; g_app->core.streamingModelProgress(idx, resident_chunks, total_chunks); return total_chunks; } // Combined byte progress for the loading bar: total geometry, bytes the current // view needs (contribution-culled), and how much of that is loaded. Doubles so // JS gets exact byte counts well past 2 GB. extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_total_c() { if (!g_app) return 0.0; std::uint64_t total_bytes = 0, needed_bytes = 0, loaded_bytes = 0; g_app->core.streamingByteProgress(total_bytes, needed_bytes, loaded_bytes); return double(total_bytes); } extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_needed_c() { if (!g_app) return 0.0; std::uint64_t total_bytes = 0, needed_bytes = 0, loaded_bytes = 0; g_app->core.streamingByteProgress(total_bytes, needed_bytes, loaded_bytes); return double(needed_bytes); } extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_loaded_c() { if (!g_app) return 0.0; std::uint64_t total_bytes = 0, needed_bytes = 0, loaded_bytes = 0; g_app->core.streamingByteProgress(total_bytes, needed_bytes, loaded_bytes); return double(loaded_bytes); } int main(int /*argc*/, char** /*argv*/) { Log::info() << "ifcviewer-web: starting"; g_app = new AppState(); // Default to the web mouse scheme: LMB orbit, MMB pan, RMB select/marquee. g_app->core.setNavPreset("web"); g_app->core.initWgpuAsyncWeb([](bool ok) { if (!ok) { Log::warn() << "ifcviewer-web: wgpu init failed"; return; } if (!g_app->core.buildPipelines()) { Log::warn() << "ifcviewer-web: buildPipelines failed"; return; } // HiZ + edge + pick pipelines: built up-front to match the // desktop path. ViewportCore::shutdown expects each resource // to be either constructed or null, so building them all here // keeps teardown symmetric. g_app->core.buildHizPipeline(); g_app->core.buildEdgePipeline(); g_app->core.buildPickPipeline(); // Load the embedded sample sidecar (mounted into MEMFS via // --embed-file in CMakeLists.txt). The sample stays on the // synchronous MEMFS read; user-picked files go through the // Blob.slice byte-range path (load_sidecar_from_blob_c) so large // sidecars never enter the wasm heap. if (!g_app->core.loadSidecarFromPath("/sample.ifcview")) { Log::warn() << "ifcviewer-web: sample sidecar load failed"; } g_app->ready = true; // Now that the camera + render path are live, start listening for // orbit/pan/zoom input. (Pick is still deferred — it needs the // async buffer-readback rewrite before it's safe on web.) installInputHandlers(g_app); // Hand the app pointer to the JS-side RAF loop (set up in // 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); }); return 0; }