diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt
new file mode 100644
index 0000000000..4e81d2ac8b
--- /dev/null
+++ b/src/ifcviewer-web/CMakeLists.txt
@@ -0,0 +1,98 @@
+################################################################################
+# #
+# 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 (Emscripten + WebGPU) build root.
+#
+# This is a SEPARATE root from cmake/CMakeLists.txt — the desktop CMake
+# pulls in Qt, OpenCASCADE, IfcGeom, CGAL, Boost, none of which exist or
+# make sense for wasm. The web build only needs IfcViewerCore (the
+# Qt-free, OpenCASCADE-free static lib carved out of src/ifcviewer)
+# plus this main_web.cpp scaffold.
+#
+# Usage:
+# source /path/to/emsdk_env.sh
+# emcmake cmake -S src/ifcviewer-web -B build-web
+# ninja -C build-web
+# python3 -m http.server --directory build-web 8080
+# # open http://localhost:8080/IfcViewerWeb.html
+#
+# Why a separate root and not a sub-add under cmake/CMakeLists.txt:
+# making every find_package() conditional on (NOT EMSCRIPTEN) would
+# pollute the desktop build with web-only branches it never exercises.
+# Better to keep the web path's machinery local to this directory.
+
+cmake_minimum_required(VERSION 3.21)
+project(IfcViewerWeb LANGUAGES CXX)
+
+if(NOT EMSCRIPTEN)
+ message(FATAL_ERROR
+ "src/ifcviewer-web requires the Emscripten toolchain. "
+ "Re-run via `emcmake cmake ...`.")
+endif()
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+# Allow find_package to look at the host system for header-only deps
+# (Eigen3 is the only one we need). Emscripten's CMake toolchain
+# defaults this to ONLY, which restricts searches to the emcc sysroot
+# and blocks the system Eigen3Config.cmake. Eigen is pure-header so
+# pulling its include path from the host is completely safe.
+set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
+set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
+
+# --- IfcViewerCore: portable runtime subset ----------------------------------
+#
+# Build IfcViewerCore by adding src/ifcviewer as a subdir. The Qt /
+# IfcGeom / OpenCASCADE-coupled IfcViewer target inside that CMakeLists
+# will fail under emcc (find_package(Qt6) etc. won't resolve), so the
+# subdir's full build can't be reached here. We add it with EXCLUDE_FROM_ALL
+# so only the targets we explicitly reference (IfcViewerCore) are built.
+# Eigen and wgpu_native (the latter becomes a no-op interface under
+# EMSCRIPTEN — see ifcviewer/CMakeLists.txt) get pulled in transitively.
+#
+# TODO(web): when src/ifcviewer/CMakeLists.txt is taught to skip the
+# Qt-using IfcViewer target under EMSCRIPTEN, we can drop EXCLUDE_FROM_ALL.
+set(IFCVIEWER_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../ifcviewer")
+add_subdirectory(${IFCVIEWER_DIR} ifcviewer EXCLUDE_FROM_ALL)
+
+# --- The web executable ------------------------------------------------------
+
+add_executable(IfcViewerWeb main_web.cpp)
+target_link_libraries(IfcViewerWeb PRIVATE IfcViewerCore)
+target_link_options(IfcViewerWeb PRIVATE
+ # Asyncify lets us await wgpu's RequestAdapter/RequestDevice/MapAsync
+ # as if they were synchronous. Adds ~30-50% wasm size and a small
+ # per-await runtime cost; acceptable for the spike, revisit when
+ # we ship.
+ "-sASYNCIFY"
+ # 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).
+ "-sALLOW_MEMORY_GROWTH=1"
+ "-sINITIAL_MEMORY=268435456" # 256 MB
+ "-sMAXIMUM_MEMORY=2147483648" # 2 GB
+ # FETCH lets emscripten_fetch issue HTTP Range requests for the
+ # sidecar byte-range loader. Not used yet by the scaffold but
+ # needed by the upcoming #27 web streaming I/O backend.
+ "-sFETCH=1"
+ # 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")
diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp
new file mode 100644
index 0000000000..66a7bdc715
--- /dev/null
+++ b/src/ifcviewer-web/main_web.cpp
@@ -0,0 +1,197 @@
+/********************************************************************************
+ * *
+ * 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 . *
+ * *
+ ********************************************************************************/
+
+// Phase-B-step-3 scaffold for the web target. Brings up a wgpu instance
+// against a #canvas via the emdawnwebgpu port, requests adapter+device
+// asynchronously, configures the surface, and renders a clear color on
+// each requestAnimationFrame tick. No sidecar load, no pipelines, no
+// scene state yet — the goal of this commit is "something renders in a
+// browser tab" so the build + canvas + wgpu plumbing is end-to-end
+// verified before we wire in IfcViewerCore.
+
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+
+namespace {
+
+// Async-arrived handles. Populated by the requestAdapter/requestDevice
+// callback chain in startup(); render() short-circuits until they're
+// all set. Keeps the path single-threaded — the JS event loop drives
+// progress between callbacks.
+WGPUInstance g_instance = nullptr;
+WGPUAdapter g_adapter = nullptr;
+WGPUDevice g_device = nullptr;
+WGPUQueue g_queue = nullptr;
+WGPUSurface g_surface = nullptr;
+WGPUTextureFormat g_surface_format = WGPUTextureFormat_Undefined;
+bool g_main_loop_started = false;
+
+// Canvas dimensions in CSS pixels. emscripten reports the canvas size in
+// CSS pixels but the GPU surface wants device pixels; we keep things at
+// 1× DPR for the scaffold and re-derive on resize once we wire input.
+int g_width = 1280;
+int g_height = 800;
+
+void frame() {
+ if (!g_surface || !g_device) return;
+
+ WGPUSurfaceTexture st = {};
+ wgpuSurfaceGetCurrentTexture(g_surface, &st);
+ if (st.status != WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal &&
+ st.status != WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal) {
+ // Lost / outdated / OOM / device-lost — log once and bail out
+ // of this frame so we don't queue work against a torn surface.
+ static int s_complained = 0;
+ if (s_complained++ < 4) {
+ std::fprintf(stderr,
+ "[viewer-web] surface texture status %d; skipping frame\n",
+ int(st.status));
+ }
+ return;
+ }
+
+ WGPUTextureView view = wgpuTextureCreateView(st.texture, nullptr);
+
+ WGPURenderPassColorAttachment ca = {};
+ ca.view = view;
+ ca.loadOp = WGPULoadOp_Clear;
+ ca.storeOp = WGPUStoreOp_Store;
+ ca.clearValue = {0.18, 0.21, 0.28, 1.0}; // BonsaiViewer slate background
+ ca.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
+
+ WGPURenderPassDescriptor rp_desc = {};
+ rp_desc.colorAttachmentCount = 1;
+ rp_desc.colorAttachments = &ca;
+
+ WGPUCommandEncoderDescriptor enc_desc = {};
+ WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(g_device, &enc_desc);
+ WGPURenderPassEncoder rp = wgpuCommandEncoderBeginRenderPass(enc, &rp_desc);
+ wgpuRenderPassEncoderEnd(rp);
+ wgpuRenderPassEncoderRelease(rp);
+
+ WGPUCommandBufferDescriptor cb_desc = {};
+ WGPUCommandBuffer cb = wgpuCommandEncoderFinish(enc, &cb_desc);
+ wgpuQueueSubmit(g_queue, 1, &cb);
+
+ wgpuCommandBufferRelease(cb);
+ wgpuCommandEncoderRelease(enc);
+ wgpuTextureViewRelease(view);
+ wgpuTextureRelease(st.texture);
+}
+
+void configure_surface() {
+ WGPUSurfaceCapabilities caps = {};
+ if (wgpuSurfaceGetCapabilities(g_surface, g_adapter, &caps) != WGPUStatus_Success
+ || caps.formatCount == 0) {
+ std::fprintf(stderr, "[viewer-web] surface has no formats\n");
+ return;
+ }
+ g_surface_format = caps.formats[0];
+ wgpuSurfaceCapabilitiesFreeMembers(caps);
+
+ WGPUSurfaceConfiguration cfg = {};
+ cfg.device = g_device;
+ cfg.format = g_surface_format;
+ cfg.usage = WGPUTextureUsage_RenderAttachment;
+ cfg.width = uint32_t(g_width);
+ cfg.height = uint32_t(g_height);
+ cfg.alphaMode = WGPUCompositeAlphaMode_Auto;
+ cfg.presentMode = WGPUPresentMode_Fifo;
+ wgpuSurfaceConfigure(g_surface, &cfg);
+
+ if (!g_main_loop_started) {
+ emscripten_set_main_loop(frame, 0, /*simulate_infinite=*/0);
+ g_main_loop_started = true;
+ std::fprintf(stderr,
+ "[viewer-web] surface configured (%dx%d format=%d); RAF loop started\n",
+ g_width, g_height, int(g_surface_format));
+ }
+}
+
+void on_device_ready(WGPURequestDeviceStatus status, WGPUDevice device,
+ WGPUStringView message, void* /*ud1*/, void* /*ud2*/) {
+ if (status != WGPURequestDeviceStatus_Success || !device) {
+ std::fprintf(stderr, "[viewer-web] requestDevice failed: %.*s\n",
+ int(message.length), message.data ? message.data : "");
+ return;
+ }
+ g_device = device;
+ g_queue = wgpuDeviceGetQueue(device);
+
+ WGPUEmscriptenSurfaceSourceCanvasHTMLSelector canvas = {};
+ canvas.chain.sType = WGPUSType_EmscriptenSurfaceSourceCanvasHTMLSelector;
+ static const char* kSelector = "#viewer-canvas";
+ canvas.selector.data = kSelector;
+ canvas.selector.length = std::strlen(kSelector);
+
+ WGPUSurfaceDescriptor sd = {};
+ sd.nextInChain = &canvas.chain;
+ g_surface = wgpuInstanceCreateSurface(g_instance, &sd);
+ if (!g_surface) {
+ std::fprintf(stderr,
+ "[viewer-web] wgpuInstanceCreateSurface returned null "
+ "(canvas '#viewer-canvas' missing?)\n");
+ return;
+ }
+ configure_surface();
+}
+
+void on_adapter_ready(WGPURequestAdapterStatus status, WGPUAdapter adapter,
+ WGPUStringView message, void* /*ud1*/, void* /*ud2*/) {
+ if (status != WGPURequestAdapterStatus_Success || !adapter) {
+ std::fprintf(stderr, "[viewer-web] requestAdapter failed: %.*s\n",
+ int(message.length), message.data ? message.data : "");
+ return;
+ }
+ g_adapter = adapter;
+
+ WGPUDeviceDescriptor dd = {};
+ WGPURequestDeviceCallbackInfo cb = {};
+ cb.mode = WGPUCallbackMode_AllowSpontaneous;
+ cb.callback = on_device_ready;
+ wgpuAdapterRequestDevice(adapter, &dd, cb);
+}
+
+} // namespace
+
+int main() {
+ WGPUInstanceDescriptor desc = {};
+ g_instance = wgpuCreateInstance(&desc);
+ if (!g_instance) {
+ std::fprintf(stderr, "[viewer-web] wgpuCreateInstance returned null\n");
+ return 1;
+ }
+
+ WGPURequestAdapterOptions opts = {};
+ WGPURequestAdapterCallbackInfo cb = {};
+ cb.mode = WGPUCallbackMode_AllowSpontaneous;
+ cb.callback = on_adapter_ready;
+ wgpuInstanceRequestAdapter(g_instance, &opts, cb);
+
+ // main() returns; the browser keeps the JS event loop running so
+ // the async adapter/device callbacks above land naturally and the
+ // main loop kicks off from configure_surface().
+ return 0;
+}
diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html
new file mode 100644
index 0000000000..1e421ff2cd
--- /dev/null
+++ b/src/ifcviewer-web/shell.html
@@ -0,0 +1,49 @@
+
+
+
+
+IfcViewer (web)
+
+
+
+
+Starting…
+
+
+{{{ SCRIPT }}}
+
+
diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt
index 19558247a6..ffc87e436a 100644
--- a/src/ifcviewer/CMakeLists.txt
+++ b/src/ifcviewer/CMakeLists.txt
@@ -19,15 +19,26 @@
message("Running CMakeLists.txt in /src/ifcviewer")
-set(QT_VERSION 6 CACHE STRING "Qt version")
-find_package(Qt${QT_VERSION} COMPONENTS Core Gui REQUIRED PATHS ${QT_DIR})
+# Qt is desktop-only. Under Emscripten this directory is reached via
+# src/ifcviewer-web/CMakeLists.txt which only consumes IfcViewerCore;
+# the IfcViewer (Qt-using) target below is skipped entirely.
+if(NOT EMSCRIPTEN)
+ set(QT_VERSION 6 CACHE STRING "Qt version")
+ find_package(Qt${QT_VERSION} COMPONENTS Core Gui REQUIRED PATHS ${QT_DIR})
+endif()
# Eigen3 — used by Federation matrices, ViewportWindow's instance compose,
# the per-model coordinate-operation matrices stored on ModelGpuData, and
-# everywhere a 4x4 transform shows up.
+# everywhere a 4x4 transform shows up. Header-only, works under Emscripten.
find_package(Eigen3 REQUIRED)
# wgpu-native — fetched as a pre-built binary release from upstream.
+# Under Emscripten this whole block is skipped; the web build links
+# against Dawn's webgpu.h via the emdawnwebgpu port instead. The
+# `wgpu_native` link target is created as an INTERFACE in that branch
+# (see the end of this block) so consumers' target_link_libraries lines
+# work uniformly.
+if(NOT EMSCRIPTEN)
# Pin the version with WGPU_NATIVE_VERSION; bump to pull a newer release.
set(WGPU_NATIVE_VERSION "v29.0.0.0" CACHE STRING "wgpu-native release tag")
@@ -105,6 +116,17 @@ endif()
set(WGPU_NATIVE_LIB_DIR "${wgpu_native_SOURCE_DIR}/lib" CACHE INTERNAL
"Directory containing the wgpu-native shared library")
+else() # EMSCRIPTEN — Dawn's webgpu.h via the emdawnwebgpu port instead.
+ # Marker interface so downstream `target_link_libraries(... wgpu_native)`
+ # lines work uniformly across desktop and web. Activating the
+ # emdawnwebgpu port both at compile and link via INTERFACE so any
+ # TU that #includes picks up the port's vendored
+ # header without each target having to re-declare the flag.
+ add_library(wgpu_native INTERFACE)
+ target_compile_options(wgpu_native INTERFACE "--use-port=emdawnwebgpu")
+ target_link_options(wgpu_native INTERFACE "--use-port=emdawnwebgpu")
+endif()
+
# IfcViewerCore: the Qt-free, OpenCASCADE-free runtime subset. Buffer
# pool, chunk planner, instance composition, sidecar cache, streaming
# I/O, LOD builder, vertex quantisation — everything needed to load a
@@ -155,7 +177,9 @@ endif()
install(TARGETS IfcViewerCore EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
# IfcViewer: the Qt + IfcGeom + OpenCASCADE shell — everything in this
-# directory NOT already pulled into IfcViewerCore above.
+# directory NOT already pulled into IfcViewerCore above. Desktop-only;
+# Emscripten builds consume IfcViewerCore directly via src/ifcviewer-web.
+if(NOT EMSCRIPTEN)
file(GLOB _ifcviewer_all_cpp ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
file(GLOB _ifcviewer_all_h ${CMAKE_CURRENT_SOURCE_DIR}/*.h)
set(IFCVIEWER_FILES ${_ifcviewer_all_cpp} ${_ifcviewer_all_h})
@@ -248,6 +272,8 @@ else()
install(FILES "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_lib}" DESTINATION lib)
endif()
-if(BUILD_BONSAIVIEWER_TESTS)
+endif() # NOT EMSCRIPTEN
+
+if(BUILD_BONSAIVIEWER_TESTS AND NOT EMSCRIPTEN)
add_subdirectory(tests)
endif()