diff --git a/CMakeLists.txt b/CMakeLists.txt index f773196c0..410a3eaa3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,9 @@ else() add_compile_definitions(MMAPPER_PACKAGE_TYPE="${PACKAGE_TYPE_NORMALIZED}") endif() +# Required for newer GLM versions that mark gtx/ extensions as experimental +add_compile_definitions(GLM_ENABLE_EXPERIMENTAL) + set(MMAPPER_QT_COMPONENTS Core Widgets Network OpenGL OpenGLWidgets Test) if(WITH_WEBSOCKET) list(APPEND MMAPPER_QT_COMPONENTS WebSockets) @@ -78,6 +81,11 @@ if(Qt6OpenGL_FOUND) if(EMSCRIPTEN) set(QT_HAS_GLES TRUE) set(QT_HAS_OPENGL FALSE) + elseif(APPLE) + # Force OpenGL on macOS - the try_compile check can fail with some Qt versions + # but macOS always has OpenGL 3.3 support via the core profile + set(QT_HAS_GLES FALSE) + set(QT_HAS_OPENGL TRUE) else() file(WRITE ${CMAKE_BINARY_DIR}${CMAKE_FILES_DIRECTORY}/CMakeTmp/CheckQtGLES30.cpp "#include @@ -423,7 +431,10 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wno-extra-semi-stmt) # enabled only for src/ directory # require explicit template parameters when deduction guides are missing - add_compile_options(-Werror=ctad-maybe-unsupported) + # NOTE: Disabled for Qt 6.10+ because moc-generated code uses CTAD without deduction guides + if(Qt6_VERSION VERSION_LESS "6.10.0") + add_compile_options(-Werror=ctad-maybe-unsupported) + endif() # always errors add_compile_options(-Werror=cast-qual) # always a mistake unless you added the qualifier yourself. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 03f8daaa6..c1993894f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -686,7 +686,7 @@ if(EMSCRIPTEN) -sFULL_ES3=1 -sMAX_WEBGL_VERSION=2 -sMIN_WEBGL_VERSION=2 - -sASSERTIONS + -sASSERTIONS=0 -sASYNCIFY -Os ) @@ -1133,14 +1133,28 @@ if(APPLE) # Bundle the libraries with the binary find_program(MACDEPLOYQT_APP macdeployqt) message(" - macdeployqt path: ${MACDEPLOYQT_APP}") - add_custom_command( - TARGET mmapper - POST_BUILD - COMMAND ${MACDEPLOYQT_APP} ${CMAKE_CURRENT_BINARY_DIR}/mmapper.app -libpath ${QTKEYCHAIN_LIBRARY_DIR} - WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} - COMMENT "Deploying the Qt Framework onto the bundle" - VERBATIM - ) + # Qt 6.8+ has a bug where macdeployqt -libpath fails with "Missing library search path" + # The library is still found via @rpath, so we can safely omit -libpath for Qt 6.8+ + if(Qt6Core_VERSION VERSION_GREATER_EQUAL "6.8.0") + message(" - Qt 6.8+ detected: skipping -libpath option (macdeployqt bug workaround)") + add_custom_command( + TARGET mmapper + POST_BUILD + COMMAND ${MACDEPLOYQT_APP} ${CMAKE_CURRENT_BINARY_DIR}/mmapper.app + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + COMMENT "Deploying the Qt Framework onto the bundle" + VERBATIM + ) + else() + add_custom_command( + TARGET mmapper + POST_BUILD + COMMAND ${MACDEPLOYQT_APP} ${CMAKE_CURRENT_BINARY_DIR}/mmapper.app -libpath ${QTKEYCHAIN_LIBRARY_DIR} + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + COMMENT "Deploying the Qt Framework onto the bundle" + VERBATIM + ) + endif() # Codesign the bundle without a personal certificate find_program(CODESIGN_APP codesign) diff --git a/src/clock/mumeclock.cpp b/src/clock/mumeclock.cpp index c27a02a5e..167a71781 100644 --- a/src/clock/mumeclock.cpp +++ b/src/clock/mumeclock.cpp @@ -78,7 +78,15 @@ class NODISCARD QME final public: NODISCARD static int keyToValue(const QString &key) { return g_qme.keyToValue(key.toUtf8()); } - NODISCARD static QString valueToKey(const int value) { return g_qme.valueToKey(value); } + NODISCARD static QString valueToKey(const int value) + { +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + // Qt 6.10+ changed valueToKey() signature from int to quint64 + return g_qme.valueToKey(static_cast(value)); +#else + return g_qme.valueToKey(value); +#endif + } NODISCARD static int keyCount() { return g_qme.keyCount(); } }; diff --git a/src/display/MapCanvasData.h b/src/display/MapCanvasData.h index 886c625bf..1601defdb 100644 --- a/src/display/MapCanvasData.h +++ b/src/display/MapCanvasData.h @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -97,10 +98,53 @@ struct NODISCARD ScaleFactor final } }; +// Abstraction to get size from either QWidget or QWindow +struct NODISCARD SizeSource final +{ +private: + QWidget *m_widget = nullptr; + QWindow *m_window = nullptr; + +public: + explicit SizeSource(QWidget &widget) + : m_widget{&widget} + {} + explicit SizeSource(QWindow &window) + : m_window{&window} + {} + + NODISCARD int width() const + { + if (m_widget) + return m_widget->width(); + if (m_window) + return m_window->width(); + return 0; + } + + NODISCARD int height() const + { + if (m_widget) + return m_widget->height(); + if (m_window) + return m_window->height(); + return 0; + } + + NODISCARD QRect rect() const + { + if (m_widget) + return m_widget->rect(); + if (m_window) + return QRect(0, 0, m_window->width(), m_window->height()); + return QRect(); + } +}; + struct NODISCARD MapCanvasViewport { private: - QWidget &m_sizeWidget; + SizeSource m_sizeSource; public: glm::mat4 m_viewProj{1.f}; @@ -109,16 +153,22 @@ struct NODISCARD MapCanvasViewport int m_currentLayer = 0; public: - explicit MapCanvasViewport(QWidget &sizeWidget) - : m_sizeWidget{sizeWidget} + explicit MapCanvasViewport(QWidget &sizeSource) + : m_sizeSource{sizeSource} + {} + +#ifdef __EMSCRIPTEN__ + explicit MapCanvasViewport(QWindow &sizeSource) + : m_sizeSource{sizeSource} {} +#endif public: - NODISCARD auto width() const { return m_sizeWidget.width(); } - NODISCARD auto height() const { return m_sizeWidget.height(); } + NODISCARD auto width() const { return m_sizeSource.width(); } + NODISCARD auto height() const { return m_sizeSource.height(); } NODISCARD Viewport getViewport() const { - const auto &r = m_sizeWidget.rect(); + const auto &r = m_sizeSource.rect(); return Viewport{glm::ivec2{r.x(), r.y()}, glm::ivec2{r.width(), r.height()}}; } NODISCARD float getTotalScaleFactor() const { return m_scaleFactor.getTotal(); } diff --git a/src/display/MapCanvasRoomDrawer.cpp b/src/display/MapCanvasRoomDrawer.cpp index f7364ed80..484a7e352 100644 --- a/src/display/MapCanvasRoomDrawer.cpp +++ b/src/display/MapCanvasRoomDrawer.cpp @@ -7,6 +7,7 @@ #include "../configuration/NamedConfig.h" #include "../configuration/configuration.h" #include "../global/Array.h" +#include "../global/ConfigConsts-Computed.h" #include "../global/ConfigConsts.h" #include "../global/EnumIndexedArray.h" #include "../global/Flags.h" @@ -1119,17 +1120,30 @@ FutureSharedMapBatchFinisher generateMapDataFinisher(const mctp::MapCanvasTextur { const auto visitRoomOptions = getVisitRoomOptions(); - return std::async(std::launch::async, + // WASM: Use deferred (synchronous) execution to avoid context invalidation during async. + // Desktop: Use async execution for better performance. + constexpr auto launchPolicy = (CURRENT_PLATFORM == PlatformEnum::Wasm) ? std::launch::deferred + : std::launch::async; + + return std::async(launchPolicy, [textures, font, map, visitRoomOptions]() -> SharedMapBatchFinisher { + // WASM: Check if WebGL context was lost before starting + if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { + if (MapCanvas::isWasmContextLost()) { + qWarning() << "[generateMapDataFinisher] context lost, aborting"; + return SharedMapBatchFinisher{}; + } + } + ThreadLocalNamedColorRaii tlRaii{visitRoomOptions.canvasColors, visitRoomOptions.colorSettings}; - DECL_TIMER(t, "[ASYNC] generateAllLayerMeshes"); + DECL_TIMER(t, "generateAllLayerMeshes"); ProgressCounter dummyPc; map.checkConsistency(dummyPc); const auto layerToRooms = std::invoke([map]() -> LayerToRooms { - DECL_TIMER(t2, "[ASYNC] generateBatches.layerToRooms"); + DECL_TIMER(t2, "generateBatches.layerToRooms"); LayerToRooms ltr; map.getRooms().for_each([&map, <r](const RoomId id) { const auto &r = map.getRoomHandle(id); @@ -1140,6 +1154,15 @@ FutureSharedMapBatchFinisher generateMapDataFinisher(const mctp::MapCanvasTextur return ltr; }); + // WASM: Check again before the expensive mesh generation + if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { + if (MapCanvas::isWasmContextLost()) { + qWarning() << "[generateMapDataFinisher] context lost before " + "mesh gen, aborting"; + return SharedMapBatchFinisher{}; + } + } + auto result = std::make_shared(); auto &data = deref(result); generateAllLayerMeshes(data, diff --git a/src/display/mapcanvas.cpp b/src/display/mapcanvas.cpp index 42abf0437..e86927f60 100644 --- a/src/display/mapcanvas.cpp +++ b/src/display/mapcanvas.cpp @@ -32,7 +32,6 @@ #include #include -#include #include #include #include @@ -53,6 +52,33 @@ NODISCARD static NonOwningPointer &primaryMapCanvas() return primary; } +#ifdef __EMSCRIPTEN__ +// WASM: QOpenGLWindow-based constructor +MapCanvas::MapCanvas(MapData &mapData, PrespammedPath &prespammedPath, Mmapper2Group &groupManager) + : QOpenGLWindow{QOpenGLWindow::NoPartialUpdate} + , MapCanvasViewport{static_cast(*this)} + , MapCanvasInputState{prespammedPath} + , m_mapScreen{static_cast(*this)} + , m_opengl{} + , m_glFont{m_opengl} + , m_data{mapData} + , m_groupManager{groupManager} +{ + NonOwningPointer &pmc = primaryMapCanvas(); + if (pmc == nullptr) { + pmc = this; + } + + // Set up surface format for WebGL 2.0 + QSurfaceFormat format; + format.setRenderableType(QSurfaceFormat::OpenGLES); + format.setVersion(3, 0); // WebGL 2.0 = OpenGL ES 3.0 + format.setDepthBufferSize(24); + format.setStencilBufferSize(8); + setFormat(format); +} +#else +// Desktop: QOpenGLWidget-based constructor MapCanvas::MapCanvas(MapData &mapData, PrespammedPath &prespammedPath, Mmapper2Group &groupManager, @@ -71,10 +97,10 @@ MapCanvas::MapCanvas(MapData &mapData, pmc = this; } - setCursor(Qt::OpenHandCursor); - grabGesture(Qt::PinchGesture); + setCanvasCursor(Qt::OpenHandCursor); setContextMenuPolicy(Qt::CustomContextMenu); } +#endif MapCanvas::~MapCanvas() { @@ -91,6 +117,37 @@ MapCanvas *MapCanvas::getPrimary() return primaryMapCanvas(); } +QWidget *MapCanvas::getParentWidget() const +{ +#ifdef __EMSCRIPTEN__ + return m_containerWidget; +#else + return qobject_cast(parent()); +#endif +} + +void MapCanvas::setCanvasCursor(const QCursor &cursor) +{ + if (QWidget *w = getParentWidget()) { + w->setCursor(cursor); + } +} + +QCursor MapCanvas::getCanvasCursor() const +{ + if (QWidget *w = getParentWidget()) { + return w->cursor(); + } + return QCursor(); +} + +void MapCanvas::showCanvasTooltip(const QPoint &localPos, const QString &text) +{ + if (QWidget *w = getParentWidget()) { + QToolTip::showText(w->mapToGlobal(localPos), text, w, w->rect(), 5000); + } +} + void MapCanvas::slot_layerUp() { ++m_currentLayer; @@ -123,7 +180,7 @@ void MapCanvas::slot_setCanvasMouseMode(const CanvasMouseModeEnum mode) switch (mode) { case CanvasMouseModeEnum::MOVE: - setCursor(Qt::OpenHandCursor); + setCanvasCursor(Qt::OpenHandCursor); break; default: @@ -131,7 +188,7 @@ void MapCanvas::slot_setCanvasMouseMode(const CanvasMouseModeEnum mode) case CanvasMouseModeEnum::RAYPICK_ROOMS: case CanvasMouseModeEnum::SELECT_CONNECTIONS: case CanvasMouseModeEnum::CREATE_INFOMARKS: - setCursor(Qt::CrossCursor); + setCanvasCursor(Qt::CrossCursor); break; case CanvasMouseModeEnum::SELECT_ROOMS: @@ -139,7 +196,7 @@ void MapCanvas::slot_setCanvasMouseMode(const CanvasMouseModeEnum mode) case CanvasMouseModeEnum::CREATE_CONNECTIONS: case CanvasMouseModeEnum::CREATE_ONEWAY_CONNECTIONS: case CanvasMouseModeEnum::SELECT_INFOMARKS: - setCursor(Qt::ArrowCursor); + setCanvasCursor(Qt::ArrowCursor); break; } @@ -296,44 +353,12 @@ void MapCanvas::slot_onForcedPositionChange() bool MapCanvas::event(QEvent *const event) { - auto tryHandlePinchZoom = [this, event]() -> bool { - if (event->type() != QEvent::Gesture) { - return false; - } - - const auto *const gestureEvent = dynamic_cast(event); - if (gestureEvent == nullptr) { - return false; - } - - // Zoom in / out - QGesture *const gesture = gestureEvent->gesture(Qt::PinchGesture); - const auto *const pinch = dynamic_cast(gesture); - if (pinch == nullptr) { - return false; - } - - const QPinchGesture::ChangeFlags changeFlags = pinch->changeFlags(); - if (changeFlags & QPinchGesture::ScaleFactorChanged) { - const auto pinchFactor = static_cast(pinch->totalScaleFactor()); - m_scaleFactor.setPinch(pinchFactor); - if ((false)) { - zoomChanged(); // Don't call this here, because it's not true yet. - } - } - if (pinch->state() == Qt::GestureFinished) { - m_scaleFactor.endPinch(); - zoomChanged(); // might not have actually changed - } - update(); - return true; - }; - - if (tryHandlePinchZoom()) { - return true; - } - + // Note: Must use #ifdef here because QOpenGLWidget is not declared on WASM +#ifdef __EMSCRIPTEN__ + return QOpenGLWindow::event(event); +#else return QOpenGLWidget::event(event); +#endif } void MapCanvas::slot_createRoom() @@ -416,8 +441,8 @@ void MapCanvas::mousePressEvent(QMouseEvent *const event) MAYBE_UNUSED const bool hasAlt = (event->modifiers() & Qt::ALT) != 0u; if (hasLeftButton && hasAlt) { - m_altDragState.emplace(AltDragState{event->pos(), cursor()}); - setCursor(Qt::ClosedHandCursor); + m_altDragState.emplace(AltDragState{event->pos(), getCanvasCursor()}); + setCanvasCursor(Qt::ClosedHandCursor); event->accept(); return; } @@ -470,7 +495,7 @@ void MapCanvas::mousePressEvent(QMouseEvent *const event) break; case CanvasMouseModeEnum::MOVE: if (hasLeftButton && hasSel1()) { - setCursor(Qt::ClosedHandCursor); + setCanvasCursor(Qt::ClosedHandCursor); startMoving(m_sel1.value()); } break; @@ -596,7 +621,7 @@ void MapCanvas::mouseMoveEvent(QMouseEvent *const event) if (m_altDragState.has_value()) { // The user released the Alt key mid-drag. if (!((event->modifiers() & Qt::ALT) != 0u)) { - setCursor(m_altDragState->originalCursor); + setCanvasCursor(m_altDragState->originalCursor); m_altDragState.reset(); // Don't accept the event; let the underlying widgets handle it. return; @@ -678,8 +703,7 @@ void MapCanvas::mouseMoveEvent(QMouseEvent *const event) if (hasLeftButton && hasSel1() && hasSel2()) { if (hasInfomarkSelectionMove()) { m_infoMarkSelectionMove->pos = getSel2().pos - getSel1().pos; - setCursor(Qt::ClosedHandCursor); - + setCanvasCursor(Qt::ClosedHandCursor); } else { m_selectedArea = true; } @@ -721,7 +745,7 @@ void MapCanvas::mouseMoveEvent(QMouseEvent *const event) m_roomSelectionMove->pos = diff; m_roomSelectionMove->wrongPlace = wrongPlace; - setCursor(wrongPlace ? Qt::ForbiddenCursor : Qt::ClosedHandCursor); + setCanvasCursor(wrongPlace ? Qt::ForbiddenCursor : Qt::ClosedHandCursor); } else { m_selectedArea = true; } @@ -770,7 +794,7 @@ void MapCanvas::mouseMoveEvent(QMouseEvent *const event) void MapCanvas::mouseReleaseEvent(QMouseEvent *const event) { if (m_altDragState.has_value()) { - setCursor(m_altDragState->originalCursor); + setCanvasCursor(m_altDragState->originalCursor); m_altDragState.reset(); event->accept(); return; @@ -785,7 +809,7 @@ void MapCanvas::mouseReleaseEvent(QMouseEvent *const event) switch (m_canvasMouseMode) { case CanvasMouseModeEnum::SELECT_INFOMARKS: - setCursor(Qt::ArrowCursor); + setCanvasCursor(Qt::ArrowCursor); if (m_mouseLeftPressed) { m_mouseLeftPressed = false; if (hasInfomarkSelectionMove()) { @@ -837,7 +861,7 @@ void MapCanvas::mouseReleaseEvent(QMouseEvent *const event) case CanvasMouseModeEnum::MOVE: stopMoving(); - setCursor(Qt::OpenHandCursor); + setCanvasCursor(Qt::OpenHandCursor); if (m_mouseLeftPressed) { m_mouseLeftPressed = false; } @@ -849,11 +873,7 @@ void MapCanvas::mouseReleaseEvent(QMouseEvent *const event) mmqt::StripAnsiEnum::Yes, mmqt::PreviewStyleEnum::ForDisplay); - QToolTip::showText(mapToGlobal(event->position().toPoint()), - message, - this, - rect(), - 5000); + showCanvasTooltip(event->position().toPoint(), message); } } break; @@ -862,8 +882,7 @@ void MapCanvas::mouseReleaseEvent(QMouseEvent *const event) break; case CanvasMouseModeEnum::SELECT_ROOMS: - setCursor(Qt::ArrowCursor); - + setCanvasCursor(Qt::ArrowCursor); // This seems very unusual. if (m_ctrlPressed && m_altPressed) { break; @@ -998,6 +1017,8 @@ void MapCanvas::mouseReleaseEvent(QMouseEvent *const event) m_ctrlPressed = false; } +#ifndef __EMSCRIPTEN__ +// QWidget-only methods (QOpenGLWindow doesn't have these) QSize MapCanvas::minimumSizeHint() const { return {sizeHint().width() / 4, sizeHint().height() / 4}; @@ -1007,6 +1028,7 @@ QSize MapCanvas::sizeHint() const { return {1280, 720}; } +#endif void MapCanvas::slot_setScroll(const glm::vec2 &worldPos) { @@ -1111,7 +1133,11 @@ void MapCanvas::screenChanged() return; } +#ifdef __EMSCRIPTEN__ + const auto newDpi = static_cast(devicePixelRatio()); // QOpenGLWindow's DPR +#else const auto newDpi = static_cast(QPaintDevice::devicePixelRatioF()); +#endif const auto oldDpi = gl.getDevicePixelRatio(); if (!utils::equals(newDpi, oldDpi)) { diff --git a/src/display/mapcanvas.h b/src/display/mapcanvas.h index cee1e14a0..f9881367e 100644 --- a/src/display/mapcanvas.h +++ b/src/display/mapcanvas.h @@ -17,6 +17,7 @@ #include "Textures.h" #include +#include #include #include #include @@ -30,7 +31,11 @@ #include #include #include +#ifdef __EMSCRIPTEN__ +#include +#else #include +#endif #include class CharacterBatch; @@ -47,9 +52,18 @@ class QWheelEvent; class QWidget; class RoomSelFakeGL; +#ifdef __EMSCRIPTEN__ +// WASM: Use QOpenGLWindow to bypass Qt's RHI compositing issues with WebGL +// QOpenGLWindow is embedded via QWidget::createWindowContainer() +class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWindow, + private MapCanvasViewport, + private MapCanvasInputState +#else +// Desktop: Use QOpenGLWidget for normal widget integration class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWidget, private MapCanvasViewport, private MapCanvasInputState +#endif { Q_OBJECT @@ -57,6 +71,24 @@ class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWidget, static constexpr const int BASESIZE = 528; // REVISIT: Why this size? 16*33 isn't special. static constexpr const int SCROLL_SCALE = 64; + // Context state management (meaningful on WASM, always returns false on desktop) + static bool isWasmContextLost(); + +#ifdef __EMSCRIPTEN__ + // WASM-only: Additional context management + static void resetWasmContextState(); + + // WASM: Helper to access the container widget + QWidget *getContainerWidget() const { return m_containerWidget; } + void setContainerWidget(QWidget *widget) { m_containerWidget = widget; } + +private: + // WASM: Static context tracking (one WebGL context per page) + static bool s_wasmInitialized; + static std::atomic s_wasmContextLost; + QWidget *m_containerWidget = nullptr; +#endif + private: struct NODISCARD FrameRateController final { @@ -153,10 +185,18 @@ class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWidget, std::optional m_altDragState; public: +#ifdef __EMSCRIPTEN__ + // WASM: QOpenGLWindow-based constructor (no parent widget) + explicit MapCanvas(MapData &mapData, + PrespammedPath &prespammedPath, + Mmapper2Group &groupManager); +#else + // Desktop: QOpenGLWidget-based constructor explicit MapCanvas(MapData &mapData, PrespammedPath &prespammedPath, Mmapper2Group &groupManager, QWidget *parent); +#endif ~MapCanvas() final; public: @@ -168,8 +208,11 @@ class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWidget, void cleanupOpenGL(); public: +#ifndef __EMSCRIPTEN__ + // QWidget-only methods (QOpenGLWindow doesn't have these) NODISCARD QSize minimumSizeHint() const override; NODISCARD QSize sizeHint() const override; +#endif using MapCanvasViewport::getTotalScaleFactor; void setZoom(float zoom) @@ -179,10 +222,37 @@ class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWidget, } NODISCARD float getRawZoom() const { return m_scaleFactor.getRaw(); } + // Pinch gesture support (called from MapWindow) + void setPinchZoom(float pinchFactor) + { + m_scaleFactor.setPinch(pinchFactor); + update(); + } + void endPinchZoom() + { + m_scaleFactor.endPinch(); + zoomChanged(); + update(); + } + + // Cursor and tooltip helpers (forward to parent widget for cross-platform support) + void setCanvasCursor(const QCursor &cursor); + NODISCARD QCursor getCanvasCursor() const; + void showCanvasTooltip(const QPoint &localPos, const QString &text); + +private: + NODISCARD QWidget *getParentWidget() const; + public: +#ifdef __EMSCRIPTEN__ + NODISCARD auto width() const { return QOpenGLWindow::width(); } + NODISCARD auto height() const { return QOpenGLWindow::height(); } + NODISCARD auto rect() const { return QRect(0, 0, width(), height()); } +#else NODISCARD auto width() const { return QOpenGLWidget::width(); } NODISCARD auto height() const { return QOpenGLWidget::height(); } NODISCARD auto rect() const { return QOpenGLWidget::rect(); } +#endif private: void onMovement(); @@ -194,9 +264,6 @@ class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWidget, protected: void initializeGL() override; void paintGL() override; - - void drawGroupCharacters(CharacterBatch &characterBatch); - void resizeGL(int width, int height) override; void mousePressEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; @@ -204,6 +271,8 @@ class NODISCARD_QOBJECT MapCanvas final : public QOpenGLWidget, void wheelEvent(QWheelEvent *event) override; bool event(QEvent *e) override; + void drawGroupCharacters(CharacterBatch &characterBatch); + private: void setAnimating(bool value); void renderLoop(); diff --git a/src/display/mapcanvas_gl.cpp b/src/display/mapcanvas_gl.cpp index fa619578f..ba5ab8e65 100644 --- a/src/display/mapcanvas_gl.cpp +++ b/src/display/mapcanvas_gl.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -48,7 +49,11 @@ #include #include +#ifdef __EMSCRIPTEN__ +#include +#else #include +#endif #include #include #include @@ -93,6 +98,25 @@ void setShowPerfStats(const bool show) } // namespace MapCanvasConfig +#ifdef __EMSCRIPTEN__ +// WASM: MakeCurrentRaii for QOpenGLWindow +class NODISCARD MakeCurrentRaii final +{ +private: + QOpenGLWindow &m_glWindow; + +public: + explicit MakeCurrentRaii(QOpenGLWindow &window) + : m_glWindow{window} + { + m_glWindow.makeCurrent(); + } + ~MakeCurrentRaii() { m_glWindow.doneCurrent(); } + + DELETE_CTORS_AND_ASSIGN_OPS(MakeCurrentRaii); +}; +#else +// Desktop: MakeCurrentRaii for QOpenGLWidget class NODISCARD MakeCurrentRaii final { private: @@ -108,6 +132,7 @@ class NODISCARD MakeCurrentRaii final DELETE_CTORS_AND_ASSIGN_OPS(MakeCurrentRaii); }; +#endif void MapCanvas::cleanupOpenGL() { @@ -168,11 +193,12 @@ void MapCanvas::reportGLVersion() return std::move(oss).str(); }); + const bool contextValid = context()->isValid(); logMsg("Current OpenGL Context:", QString("%1 (%2)") .arg(version.c_str()) // FIXME: This is a bit late to report an invalid context. - .arg(context()->isValid() ? "valid" : "invalid") + .arg(contextValid ? "valid" : "invalid") .toUtf8()); if constexpr (!NO_OPENGL) { logMsg("Highest OpenGL:", mmqt::toQByteArrayUtf8(OpenGLConfig::getGLVersionString())); @@ -181,7 +207,11 @@ void MapCanvas::reportGLVersion() logMsg("Highest GLES:", mmqt::toQByteArrayUtf8(OpenGLConfig::getESVersionString())); } +#ifdef __EMSCRIPTEN__ + logMsg("Display:", QString("%1 DPI").arg(devicePixelRatio()).toUtf8()); +#else logMsg("Display:", QString("%1 DPI").arg(QPaintDevice::devicePixelRatioF()).toUtf8()); +#endif } bool MapCanvas::isBlacklistedDriver() @@ -202,8 +232,44 @@ bool MapCanvas::isBlacklistedDriver() return false; } +// Context state tracking +#ifdef __EMSCRIPTEN__ +// WASM: Static member definitions +bool MapCanvas::s_wasmInitialized = false; +std::atomic MapCanvas::s_wasmContextLost{false}; + +bool MapCanvas::isWasmContextLost() +{ + return s_wasmContextLost.load(); +} + +void MapCanvas::resetWasmContextState() +{ + s_wasmInitialized = false; + s_wasmContextLost.store(false); +} +#else +// Desktop: Context is never "lost" in the WASM sense +bool MapCanvas::isWasmContextLost() +{ + return false; +} +#endif + void MapCanvas::initializeGL() { +#ifdef __EMSCRIPTEN__ + // WASM: Track reinitialization attempts. + // With QOpenGLWindow approach, reinit should not happen as frequently. + if (s_wasmInitialized) { + qWarning() << "[MapCanvas] initializeGL called again - WebGL context likely lost. " + << "Call resetWasmContextState() before retrying initialization."; + s_wasmContextLost.store(true); + return; + } + s_wasmInitialized = true; +#endif + OpenGL &gl = getOpenGL(); try { gl.initializeOpenGLFunctions(); @@ -213,11 +279,21 @@ void MapCanvas::initializeGL() throw std::runtime_error("unsupported driver"); } } catch (const std::exception &) { +#ifdef __EMSCRIPTEN__ + close(); // QOpenGLWindow uses close() instead of hide() + doneCurrent(); + // WASM: MapCanvas is QOpenGLWindow (not QWidget), use nullptr for dialog parent + QMessageBox::critical(nullptr, + "Unable to initialize OpenGL", + "Upgrade your video card drivers"); +#else hide(); doneCurrent(); + // Desktop: MapCanvas is QOpenGLWidget, use this for proper modality QMessageBox::critical(this, "Unable to initialize OpenGL", "Upgrade your video card drivers"); +#endif if constexpr (CURRENT_PLATFORM == PlatformEnum::Windows) { // Link to Microsoft OpenGL Compatibility Pack QDesktopServices::openUrl( @@ -233,7 +309,11 @@ void MapCanvas::initializeGL() // because the logger purposely calls std::abort() when it receives an error. initLogger(); +#ifdef __EMSCRIPTEN__ + gl.initializeRenderer(static_cast(devicePixelRatio())); +#else gl.initializeRenderer(static_cast(QPaintDevice::devicePixelRatioF())); +#endif updateMultisampling(); // REVISIT: should the font texture have the lowest ID? @@ -251,6 +331,16 @@ void MapCanvas::initializeGL() programs.early_init(); } +#ifdef __EMSCRIPTEN__ + // Clear any GL errors that may have accumulated during initialization. + // WebGL can generate errors for operations that succeed on desktop OpenGL. + { + auto &sharedFuncs = gl.getSharedFunctions(Badge{}); + Legacy::Functions &funcs = deref(sharedFuncs); + funcs.clearErrors(); + } +#endif + setConfig().canvas.showUnsavedChanges.registerChangeCallback(m_lifetime, [this]() { if (getConfig().canvas.showUnsavedChanges.get() && m_diff.highlight.has_value() && m_diff.highlight->highlights.empty()) { @@ -475,6 +565,15 @@ void MapCanvas::setViewportAndMvp(int width, int height) void MapCanvas::resizeGL(int width, int height) { +#ifdef __EMSCRIPTEN__ + // WASM: Check if WebGL context is valid + auto *ctx = context(); + if (ctx == nullptr || !ctx->isValid()) { + s_wasmContextLost.store(true); + return; + } +#endif + if (m_textures.room_highlight == nullptr) { // resizeGL called but initializeGL was not called yet return; @@ -532,6 +631,14 @@ void MapCanvas::updateBatches() void MapCanvas::updateMapBatches() { +#ifdef __EMSCRIPTEN__ + // WASM: Don't start new async batch generation if context is unstable. + // This prevents crashes in the async task. + if (s_wasmContextLost.load()) { + return; + } +#endif + RemeshCookie &remeshCookie = m_batches.remeshCookie; if (remeshCookie.isPending()) { return; @@ -638,6 +745,26 @@ void MapCanvas::actuallyPaintGL() auto &gl = getOpenGL(); gl.bindNamedColorsBuffer(); +#ifdef __EMSCRIPTEN__ + // WASM with QOpenGLWindow: Render directly to default framebuffer (no FBO) + // This avoids potential blit issues with WebGL + if (auto *ctx = QOpenGLContext::currentContext()) { + ctx->functions()->glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + gl.clear(Color{getConfig().canvas.backgroundColor}); + + if (m_data.isEmpty()) { + getGLFont().renderTextCentered("No map loaded"); + return; + } + + paintMap(); + paintBatchedInfomarks(); + paintSelections(); + paintCharacters(); + paintDifferences(); +#else + // Desktop with QOpenGLWidget: Use FBO for compositing gl.bindFbo(); gl.clear(Color{getConfig().canvas.backgroundColor}); @@ -654,6 +781,7 @@ void MapCanvas::actuallyPaintGL() gl.releaseFbo(); gl.blitFboToDefault(); +#endif } NODISCARD bool MapCanvas::Diff::isUpToDate(const Map &saved, const Map ¤t) const @@ -817,6 +945,15 @@ void MapCanvas::paintSelections() void MapCanvas::paintGL() { +#ifdef __EMSCRIPTEN__ + // WASM: Check if WebGL context is valid + auto *ctx = context(); + if (ctx == nullptr || !ctx->isValid()) { + s_wasmContextLost.store(true); + return; + } +#endif + static thread_local double longestBatchMs = 0.0; const bool showPerfStats = MapCanvasConfig::getShowPerfStats(); @@ -859,7 +996,11 @@ void MapCanvas::paintGL() const auto &afterBatches = optAfterBatches.value(); const auto afterPaint = Clock::now(); const bool calledFinish = std::invoke([this]() -> bool { +#ifdef __EMSCRIPTEN__ + if (auto *const ctxt = QOpenGLWindow::context()) { +#else if (auto *const ctxt = QOpenGLWidget::context()) { +#endif if (auto *const func = ctxt->functions()) { func->glFinish(); return true; diff --git a/src/display/mapwindow.cpp b/src/display/mapwindow.cpp index f5e12c263..d150c1dd0 100644 --- a/src/display/mapwindow.cpp +++ b/src/display/mapwindow.cpp @@ -13,6 +13,7 @@ #include +#include #include #include #include @@ -43,11 +44,30 @@ MapWindow::MapWindow(MapData &mapData, PrespammedPath &pp, Mmapper2Group &gm, QW m_gridLayout->addWidget(m_horizontalScrollBar.get(), 1, 0, 1, 1); - m_canvas = std::make_unique(mapData, pp, gm, this); +#ifdef __EMSCRIPTEN__ + // WASM: Use QOpenGLWindow embedded via createWindowContainer + // This bypasses Qt's RHI compositing issues with WebGL + m_canvas = std::make_unique(mapData, pp, gm); MapCanvas *const canvas = m_canvas.get(); + // Create a container widget to embed the QOpenGLWindow + QWidget *container = QWidget::createWindowContainer(canvas, this); + container->setMinimumSize(200, 200); + container->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + container->setFocusPolicy(Qt::StrongFocus); + canvas->setContainerWidget(container); + + m_gridLayout->addWidget(container, 0, 0, 1, 1); + m_gridLayout->setRowStretch(0, 1); + m_gridLayout->setColumnStretch(0, 1); + setMinimumSize(200, 200); +#else + // Desktop: Use QOpenGLWidget directly in layout + m_canvas = std::make_unique(mapData, pp, gm, this); + MapCanvas *const canvas = m_canvas.get(); m_gridLayout->addWidget(canvas, 0, 0, 1, 1); setMinimumSize(canvas->minimumSizeHint()); +#endif // Splash setup auto createSplashPixmap = [](const QSize &targetLogicalSize, qreal dpr) -> QPixmap { @@ -125,6 +145,9 @@ MapWindow::MapWindow(MapData &mapData, PrespammedPath &pp, Mmapper2Group &gm, QW connect(canvas, &MapCanvas::sig_mapMove, this, &MapWindow::slot_mapMove); connect(canvas, &MapCanvas::sig_zoomChanged, this, &MapWindow::slot_zoomChanged); } + + // Pinch-to-zoom gesture (handled here since MapWindow is always a QWidget) + grabGesture(Qt::PinchGesture); } void MapWindow::hideSplashImage() @@ -153,6 +176,30 @@ void MapWindow::keyReleaseEvent(QKeyEvent *const event) QWidget::keyReleaseEvent(event); } +bool MapWindow::event(QEvent *const event) +{ + // Handle pinch-to-zoom gesture + if (event->type() == QEvent::Gesture) { + const auto *const gestureEvent = dynamic_cast(event); + if (gestureEvent != nullptr) { + QGesture *const gesture = gestureEvent->gesture(Qt::PinchGesture); + const auto *const pinch = dynamic_cast(gesture); + if (pinch != nullptr) { + const QPinchGesture::ChangeFlags changeFlags = pinch->changeFlags(); + if (changeFlags & QPinchGesture::ScaleFactorChanged) { + const auto pinchFactor = static_cast(pinch->totalScaleFactor()); + m_canvas->setPinchZoom(pinchFactor); + } + if (pinch->state() == Qt::GestureFinished) { + m_canvas->endPinchZoom(); + } + return true; + } + } + } + return QWidget::event(event); +} + MapWindow::~MapWindow() = default; void MapWindow::slot_mapMove(const int dx, const int input_dy) diff --git a/src/display/mapwindow.h b/src/display/mapwindow.h index 1f9f76600..e824d9aad 100644 --- a/src/display/mapwindow.h +++ b/src/display/mapwindow.h @@ -66,10 +66,12 @@ class NODISCARD_QOBJECT MapWindow final : public QWidget public: void keyPressEvent(QKeyEvent *event) override; void keyReleaseEvent(QKeyEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - NODISCARD MapCanvas *getCanvas() const; +protected: + void resizeEvent(QResizeEvent *event) override; + bool event(QEvent *event) override; + public: void updateScrollBars(); void setZoom(float zoom); diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 00392fb0c..e27566983 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -465,7 +465,10 @@ void MainWindow::wireConnections() &MapCanvas::sig_newInfomarkSelection, this, &MainWindow::slot_newInfomarkSelection); +#ifndef __EMSCRIPTEN__ + // QOpenGLWindow doesn't have customContextMenuRequested signal connect(canvas, &QWidget::customContextMenuRequested, this, &MainWindow::slot_showContextMenu); +#endif // Group connect(m_groupManager, &Mmapper2Group::sig_log, this, &MainWindow::slot_log); @@ -1087,6 +1090,10 @@ void MainWindow::hideCanvas(const bool hide) // REVISIT: It seems that updates don't work if the canvas is hidden, // so we may want to save mapChanged() and other similar requests // and send them after we show the canvas. +#ifdef __EMSCRIPTEN__ + // For WASM, MapCanvas is QObject-based and has no show/hide methods + Q_UNUSED(hide); +#else if (MapCanvas *const canvas = getCanvas()) { if (hide) { canvas->hide(); @@ -1094,6 +1101,7 @@ void MainWindow::hideCanvas(const bool hide) canvas->show(); } } +#endif } void MainWindow::setupMenuBar() @@ -1274,7 +1282,12 @@ void MainWindow::slot_showContextMenu(const QPoint &pos) mouseMenu->addAction(mouseMode.modeCreateConnectionAct); mouseMenu->addAction(mouseMode.modeCreateOnewayConnectionAct); +#ifdef __EMSCRIPTEN__ + // On WASM, MapCanvas is a QObject without mapToGlobal; use MapWindow instead + contextMenu->popup(m_mapWindow->mapToGlobal(pos)); +#else contextMenu->popup(getCanvas()->mapToGlobal(pos)); +#endif } void MainWindow::slot_alwaysOnTop() @@ -1313,7 +1326,10 @@ void MainWindow::slot_setShowMenuBar() m_dockDialogGroup->setMouseTracking(!showMenuBar); m_dockDialogLog->setMouseTracking(!showMenuBar); m_dockDialogRoom->setMouseTracking(!showMenuBar); +#ifndef __EMSCRIPTEN__ + // setMouseTracking is QWidget-specific getCanvas()->setMouseTracking(!showMenuBar); +#endif if (showMenuBar) { menuBar()->show(); diff --git a/src/mainwindow/utils.cpp b/src/mainwindow/utils.cpp index 41b911b42..5a67eef6f 100644 --- a/src/mainwindow/utils.cpp +++ b/src/mainwindow/utils.cpp @@ -9,12 +9,18 @@ CanvasDisabler::CanvasDisabler(MapWindow &in_window) : window{in_window} { +#ifndef __EMSCRIPTEN__ + // setEnabled is QWidget-specific window.getCanvas()->setEnabled(false); +#endif } CanvasDisabler::~CanvasDisabler() { +#ifndef __EMSCRIPTEN__ + // setEnabled is QWidget-specific window.getCanvas()->setEnabled(true); +#endif window.hideSplashImage(); } diff --git a/src/opengl/LineRendering.cpp b/src/opengl/LineRendering.cpp index 74b1018e4..610990f7d 100644 --- a/src/opengl/LineRendering.cpp +++ b/src/opengl/LineRendering.cpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace mmgl { diff --git a/src/opengl/OpenGL.cpp b/src/opengl/OpenGL.cpp index 711b5c101..45247402f 100644 --- a/src/opengl/OpenGL.cpp +++ b/src/opengl/OpenGL.cpp @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -262,7 +263,14 @@ void OpenGL::initializeRenderer(const float devicePixelRatio) // REVISIT: Move this somewhere else? GLint maxSamples = 0; +#ifdef __EMSCRIPTEN__ + // WebGL doesn't support GL_TEXTURE_2D_MULTISAMPLE which is needed for our + // multisampling FBO approach. Force disable multisampling on WASM. + maxSamples = 0; + qDebug() << "WASM: Multisampling disabled"; +#else getFunctions().glGetIntegerv(GL_MAX_SAMPLES, &maxSamples); +#endif OpenGLConfig::setMaxSamples(maxSamples); m_rendererInitialized = true; diff --git a/src/opengl/legacy/Legacy.cpp b/src/opengl/legacy/Legacy.cpp index af4371b99..8393483db 100644 --- a/src/opengl/legacy/Legacy.cpp +++ b/src/opengl/legacy/Legacy.cpp @@ -369,12 +369,28 @@ void Functions::checkError() } if (fail) { +#ifdef __EMSCRIPTEN__ + // On WASM/WebGL, don't abort on GL errors - just log them. + // WebGL can generate errors in cases that work fine, and aborting + // makes debugging impossible. + qWarning() << "OpenGL error detected (WASM mode - continuing execution)"; +#else std::abort(); +#endif } #undef CASE } +int Functions::clearErrors() +{ + int count = 0; + while (Base::glGetError() != GL_NO_ERROR) { + ++count; + } + return count; +} + void Functions::configureFbo(int samples) { getFBO().configure(getPhysicalViewport(), samples); diff --git a/src/opengl/legacy/Legacy.h b/src/opengl/legacy/Legacy.h index cfddfcd28..97c85d3f4 100644 --- a/src/opengl/legacy/Legacy.h +++ b/src/opengl/legacy/Legacy.h @@ -447,6 +447,8 @@ class NODISCARD Functions : protected QOpenGLExtraFunctions, public: void checkError(); + // Clears any pending GL errors without aborting. Returns the count of errors cleared. + int clearErrors(); public: void configureFbo(int samples); diff --git a/src/opengl/legacy/VAO.cpp b/src/opengl/legacy/VAO.cpp index 1919bcec5..4e6d79f3c 100644 --- a/src/opengl/legacy/VAO.cpp +++ b/src/opengl/legacy/VAO.cpp @@ -19,7 +19,17 @@ void VAO::emplace(const SharedFunctions &sharedFunctions) throw std::runtime_error("Legacy::Functions is no longer valid"); } +#ifdef __EMSCRIPTEN__ + // Clear any pending GL errors before VAO creation + shared->clearErrors(); +#endif + shared->glGenVertexArrays(1, &m_vao); + +#ifdef __EMSCRIPTEN__ + qDebug() << "VAO::emplace - created VAO id:" << m_vao; +#endif + shared->checkError(); if (LOG_VAO_ALLOCATIONS) {