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()