From 368474a69e6425f5f49c7edffda65eb9ef09e5a0 Mon Sep 17 00:00:00 2001 From: Tim Vandermeersch Date: Sun, 31 May 2026 02:56:31 +0200 Subject: [PATCH] Initial WebAssembly build --- avogadro/CMakeLists.txt | 113 ++++++++++++++++++++++++++++++++++++++- avogadro/aboutdialog.cpp | 6 +++ avogadro/avogadro.cpp | 34 +++++++++++- avogadro/mainwindow.cpp | 11 ++++ 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/avogadro/CMakeLists.txt b/avogadro/CMakeLists.txt index a3372fea..e288eb7b 100644 --- a/avogadro/CMakeLists.txt +++ b/avogadro/CMakeLists.txt @@ -103,7 +103,7 @@ endif() include_directories(${CMAKE_CURRENT_BINARY_DIR}) # if we are building statically then we need HDF5 targets -if(NOT BUILD_SHARED_LIBS) +if(NOT BUILD_SHARED_LIBS AND USE_HDF5) find_package(HDF5 REQUIRED COMPONENTS C) endif() @@ -208,6 +208,29 @@ endif() add_executable(avogadro WIN32 MACOSX_BUNDLE ${avogadro_srcs} ${ui_srcs} ${rcc_srcs}) +if(EMSCRIPTEN) + find_program(AVOGADRO_GZIP_EXECUTABLE gzip) + target_compile_options(avogadro PRIVATE -pthread) + target_link_options(avogadro PRIVATE + -pthread + --bind + "SHELL:-sMIN_WEBGL_VERSION=2" + "SHELL:-sMAX_WEBGL_VERSION=2" + "SHELL:-sEXPORTED_RUNTIME_METHODS=['specialHTMLTargets','JSEvents']") + if(AVOGADRO_GZIP_EXECUTABLE) + add_custom_command(TARGET avogadro POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy + "$/$.wasm" + "$/$.wasm.gz.tmp" + COMMAND "${AVOGADRO_GZIP_EXECUTABLE}" -9 -f + "$/$.wasm.gz.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "$/$.wasm.gz.tmp.gz" + "$/$.wasm.gz" + COMMENT "Compressing $.wasm" + VERBATIM) + endif() +endif() target_link_libraries(avogadro Qt::Widgets Qt::Network Qt::Concurrent) if(WIN32) set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /NODEFAULTLIB:MSVCRTD") @@ -240,10 +263,98 @@ if(USE_3DCONNEXION AND (WIN32 OR APPLE)) endif() endif() +if(EMSCRIPTEN) + file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/index.html" + CONTENT [[ + + + + + Avogadro wasm + + + +
+ + + + +]]) + file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/serve-wasm.py" + CONTENT [[#!/usr/bin/env python3 +from pathlib import Path +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + + +class Handler(SimpleHTTPRequestHandler): + extensions_map = { + **SimpleHTTPRequestHandler.extensions_map, + ".wasm": "application/wasm", + } + + def do_GET(self): + parsed = urlsplit(self.path) + if parsed.path.endswith(".wasm"): + accept_encoding = self.headers.get("Accept-Encoding", "") + compressed_path = Path(self.translate_path(parsed.path + ".gz")) + if "gzip" in accept_encoding and compressed_path.is_file(): + self.send_response(200) + self.send_header("Content-Type", "application/wasm") + self.send_header("Content-Encoding", "gzip") + self.send_header("Vary", "Accept-Encoding") + self.send_header("Content-Length", str(compressed_path.stat().st_size)) + self.end_headers() + with compressed_path.open("rb") as source: + self.copyfile(source, self.wfile) + return + + super().do_GET() + + def end_headers(self): + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + self.send_header("Cross-Origin-Resource-Policy", "same-origin") + super().end_headers() + + +if __name__ == "__main__": + ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever() +]]) +endif() + install(TARGETS avogadro RUNTIME DESTINATION ${INSTALL_RUNTIME_DIR} BUNDLE DESTINATION . ) +if(EMSCRIPTEN) + install(FILES "$/$.wasm" + DESTINATION ${INSTALL_RUNTIME_DIR}) + if(AVOGADRO_GZIP_EXECUTABLE) + install(FILES "$/$.wasm.gz" + DESTINATION ${INSTALL_RUNTIME_DIR}) + endif() + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/index.html" + DESTINATION ${INSTALL_RUNTIME_DIR}) + install(PROGRAMS "${CMAKE_CURRENT_BINARY_DIR}/serve-wasm.py" + DESTINATION ${INSTALL_RUNTIME_DIR}) +endif() # Keep "add_subdirectory(lastinstall)" last: fixup_bundle needs to be # *after* all other install(TARGETS and install(FILES calls diff --git a/avogadro/aboutdialog.cpp b/avogadro/aboutdialog.cpp index 7f912821..3c4f8526 100644 --- a/avogadro/aboutdialog.cpp +++ b/avogadro/aboutdialog.cpp @@ -7,7 +7,9 @@ #include "avogadroappconfig.h" #include "ui_aboutdialog.h" +#ifndef Q_OS_WASM #include +#endif #include @@ -38,8 +40,12 @@ AboutDialog::AboutDialog(QWidget* parent_) m_ui->version->setText(html.arg("20").arg(AvogadroApp_VERSION)); m_ui->libsVersion->setText(html.arg("10").arg(version())); m_ui->qtVersion->setText(html.arg("10").arg(qVersion())); +#ifdef Q_OS_WASM + m_ui->sslVersion->setText(html.arg("10").arg(tr("Not available"))); +#else m_ui->sslVersion->setText( html.arg("10").arg(QSslSocket::sslLibraryVersionString())); +#endif // check for light or dark mode const QPalette defaultPalette; diff --git a/avogadro/avogadro.cpp b/avogadro/avogadro.cpp index eecc9036..ee6701d6 100644 --- a/avogadro/avogadro.cpp +++ b/avogadro/avogadro.cpp @@ -14,14 +14,18 @@ #include #include #include +#ifndef Q_OS_WASM #include +#endif #include #include #include // install a message handler (for Windows) #include +#ifndef Q_OS_WASM #include +#endif #include #include @@ -104,7 +108,17 @@ void myMessageOutput(QtMsgType type, const QMessageLogContext& context, // Taken from https://github.com/openscad/openscad/pull/6711 void configureOpenGLContext() { -#if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) +#if defined(Q_OS_WASM) + auto format = QSurfaceFormat::defaultFormat(); + format.setRenderableType(QSurfaceFormat::OpenGLES); + format.setVersion(3, 0); + format.setProfile(QSurfaceFormat::NoProfile); + if (format.depthBufferSize() < 24) + format.setDepthBufferSize(24); + if (format.stencilBufferSize() < 8) + format.setStencilBufferSize(8); + QSurfaceFormat::setDefaultFormat(format); +#elif defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) if (qEnvironmentVariableIsEmpty("QT_OPENGL")) { QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); @@ -172,7 +186,9 @@ int main(int argc, char* argv[]) qDebug() << "Avogadroapp version: " << AvogadroApp_VERSION; qDebug() << "Avogadrolibs version: " << Avogadro::version(); qDebug() << "Qt version: " << qVersion(); +#ifndef Q_OS_WASM qDebug() << "SSL version: " << QSslSocket::sslLibraryVersionString(); +#endif Avogadro::Application app(argc, argv); @@ -204,6 +220,13 @@ int main(int argc, char* argv[]) QStringList translationPaths; // check environment variable and local paths +#ifdef Q_OS_WASM + const QString translationsEnv = qEnvironmentVariable("AVOGADRO_TRANSLATIONS"); + if (!translationsEnv.isEmpty()) { + foreach (const QString& path, translationsEnv.split(':')) + translationPaths << path; + } +#else foreach (const QString& variable, QProcess::systemEnvironment()) { QStringList split1 = variable.split('='); if (split1[0] == "AVOGADRO_TRANSLATIONS") { @@ -211,6 +234,7 @@ int main(int argc, char* argv[]) translationPaths << path; } } +#endif translationPaths << QLibraryInfo::location(QLibraryInfo::TranslationsPath); translationPaths << QCoreApplication::applicationDirPath() + @@ -342,8 +366,14 @@ int main(int argc, char* argv[]) #if defined(Q_OS_MAC) defaultFormat.setAlphaBufferSize(8); #endif +#if defined(Q_OS_WASM) + defaultFormat.setRenderableType(QSurfaceFormat::OpenGLES); + defaultFormat.setVersion(3, 0); + defaultFormat.setProfile(QSurfaceFormat::NoProfile); +#else defaultFormat.setVersion(4, 0); defaultFormat.setProfile(QSurfaceFormat::CoreProfile); +#endif QSurfaceFormat::setDefaultFormat(defaultFormat); QStringList fileNames; @@ -386,7 +416,7 @@ int main(int argc, char* argv[]) #endif window.show(); -#ifdef Avogadro_ENABLE_RPC +#if defined(Avogadro_ENABLE_RPC) && !defined(Q_OS_WASM) // create rpc listener Avogadro::RpcListener listener; listener.start(); diff --git a/avogadro/mainwindow.cpp b/avogadro/mainwindow.cpp index 0bce99df..abfb0892 100644 --- a/avogadro/mainwindow.cpp +++ b/avogadro/mainwindow.cpp @@ -48,7 +48,9 @@ #include #include #include +#ifndef Q_OS_WASM #include +#endif #include #include #include @@ -323,6 +325,7 @@ MainWindow::MainWindow(const QStringList& fileNames, bool disableSettings) } } +#ifndef Q_OS_WASM // Clean the pixi cache if it's been more than 30 days QSettings settings; QDateTime lastCleaned = settings.value("pixi/lastCacheClean").toDateTime(); @@ -342,6 +345,7 @@ MainWindow::MainWindow(const QStringList& fileNames, bool disableSettings) settings.setValue("pixi/lastCacheClean", now); } } +#endif // Scan for pyproject.toml-based plugin packages. loadPackages(); @@ -1802,6 +1806,12 @@ QImage MainWindow::renderToImage(const QSize& size) { QImage exportImage(size, QImage::Format_ARGB32); +#ifdef Q_OS_WASM + qWarning("Image export is not implemented for the WebAssembly OpenGL window."); + return exportImage; +#endif + +#ifndef Q_OS_WASM auto* glWidget = qobject_cast(m_multiViewWidget->activeWidget()); @@ -1850,6 +1860,7 @@ QImage MainWindow::renderToImage(const QSize& size) if (ok) exportImage.setText("CML", tmpCml.c_str()); } +#endif return exportImage; }