(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;
}