From 30a3c3ce2b7e04e15cc5ee3dcc84753d42f31a4d Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sat, 30 May 2026 18:00:31 +0200 Subject: [PATCH 01/12] Add HTTP server with HTML project tree and property editor Add RiaHtmlServer built on QHttpServer (Qt6::HttpServer), started together with the GUI. It serves the project tree as nested HTML and a property editor form for any object, addressing objects by a dotted child-index path. Editing a value posts back the form, applies changes via setFromQVariant, fires fieldChangedByUi and refreshes connected editors. The full server URL is logged to the message window on startup. --- .../Application/RiaGuiApplication.cpp | 5 + .../Application/RiaGuiApplication.h | 3 + ApplicationLibCode/CMakeLists.txt | 4 + .../HttpServer/CMakeLists_files.cmake | 6 + .../HttpServer/RiaHtmlServer.cpp | 402 ++++++++++++++++++ ApplicationLibCode/HttpServer/RiaHtmlServer.h | 72 ++++ CMakeLists.txt | 5 +- 7 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 ApplicationLibCode/HttpServer/CMakeLists_files.cmake create mode 100644 ApplicationLibCode/HttpServer/RiaHtmlServer.cpp create mode 100644 ApplicationLibCode/HttpServer/RiaHtmlServer.h diff --git a/ApplicationLibCode/Application/RiaGuiApplication.cpp b/ApplicationLibCode/Application/RiaGuiApplication.cpp index 9b0b813bce..edca24a407 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.cpp +++ b/ApplicationLibCode/Application/RiaGuiApplication.cpp @@ -33,6 +33,7 @@ #include "RiaPlotWindowRedrawScheduler.h" #include "RiaPreferences.h" #include "RiaPreferencesGrid.h" +#include "RiaHtmlServer.h" #include "RiaPreferencesSystem.h" #include "RiaProjectModifier.h" #include "RiaQStringFormatter.h" @@ -180,6 +181,7 @@ RiaGuiApplication::RiaGuiApplication( int& argc, char** argv ) , RiaApplication() , m_mainWindow( nullptr ) , m_mainPlotWindow( nullptr ) + , m_htmlServer( nullptr ) { setWindowIcon( QIcon( ":/AppLogo48x48.png" ) ); @@ -522,6 +524,9 @@ void RiaGuiApplication::initialize() RiaLogging::appendLoggerInstance( std::move( fileLogger ) ); } m_socketServer = new RiaSocketServer( this ); + + m_htmlServer = new RiaHtmlServer( this ); + m_htmlServer->start(); } //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/Application/RiaGuiApplication.h b/ApplicationLibCode/Application/RiaGuiApplication.h index 67dfcbfd5e..ccdcf0e61a 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.h +++ b/ApplicationLibCode/Application/RiaGuiApplication.h @@ -37,6 +37,7 @@ class Drawable; class RIProcess; +class RiaHtmlServer; class RiaPreferences; class RiaProjectModifier; class RiaSocketServer; @@ -169,4 +170,6 @@ private slots: std::unique_ptr m_recentFileActionProvider; std::unique_ptr m_maximizeWindowGuard; + + RiaHtmlServer* m_htmlServer; }; diff --git a/ApplicationLibCode/CMakeLists.txt b/ApplicationLibCode/CMakeLists.txt index f8a671c80b..fe46d10b47 100644 --- a/ApplicationLibCode/CMakeLists.txt +++ b/ApplicationLibCode/CMakeLists.txt @@ -32,6 +32,7 @@ find_package( PrintSupport Svg Sql + HttpServer ) set(QT_LIBRARIES Qt6::Core @@ -45,6 +46,7 @@ set(QT_LIBRARIES Qt6::PrintSupport Qt6::Svg Qt6::Sql + Qt6::HttpServer ) qt_standard_project_setup() @@ -152,6 +154,7 @@ list( CommandFileInterface/CMakeLists_files.cmake CommandFileInterface/Core/CMakeLists_files.cmake SocketInterface/CMakeLists_files.cmake + HttpServer/CMakeLists_files.cmake ) # Include source file lists from *.cmake files @@ -410,6 +413,7 @@ target_include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/ReservoirDataModel/SimulationFile ${CMAKE_CURRENT_SOURCE_DIR}/ResultStatisticsCache ${CMAKE_CURRENT_SOURCE_DIR}/SocketInterface + ${CMAKE_CURRENT_SOURCE_DIR}/HttpServer ${CMAKE_CURRENT_SOURCE_DIR}/UserInterface ${CMAKE_CURRENT_SOURCE_DIR}/UserInterface/AnalysisPlots ${CMAKE_CURRENT_SOURCE_DIR}/GeoMech/GeoMechDataModel diff --git a/ApplicationLibCode/HttpServer/CMakeLists_files.cmake b/ApplicationLibCode/HttpServer/CMakeLists_files.cmake new file mode 100644 index 0000000000..0fc6eecbaf --- /dev/null +++ b/ApplicationLibCode/HttpServer/CMakeLists_files.cmake @@ -0,0 +1,6 @@ +set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RiaHtmlServer.h) + +set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RiaHtmlServer.cpp) + +list(APPEND CODE_HEADER_FILES ${SOURCE_GROUP_HEADER_FILES}) +list(APPEND CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES}) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp new file mode 100644 index 0000000000..3f2eb21c14 --- /dev/null +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -0,0 +1,402 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight 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 GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RiaHtmlServer.h" + +#include "RiaLogging.h" + +#include "RimProject.h" + +#include "cafPdmFieldHandle.h" +#include "cafPdmObjectHandle.h" +#include "cafPdmUiFieldHandle.h" +#include "cafPdmUiObjectHandle.h" +#include "cafPdmValueField.h" +#include "cafPdmXmlObjectHandle.h" + +#include +#include +#include +#include +#include + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// Minimal HTML escaping for text inserted into the generated pages. +//-------------------------------------------------------------------------------------------------- +QString htmlEscape( const QString& text ) +{ + QString escaped = text; + escaped.replace( '&', "&" ); + escaped.replace( '<', "<" ); + escaped.replace( '>', ">" ); + escaped.replace( '"', """ ); + return escaped; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaHtmlServer::RiaHtmlServer( QObject* parent ) + : QObject( parent ) + , m_httpServer( nullptr ) + , m_port( 0 ) +{ +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaHtmlServer::~RiaHtmlServer() +{ +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiaHtmlServer::start( quint16 preferredPort ) +{ + m_httpServer = new QHttpServer( this ); + + m_httpServer->route( "/", + [this]( const QHttpServerRequest& request ) -> QHttpServerResponse + { + Q_UNUSED( request ); + return QHttpServerResponse( QByteArray( "text/html; charset=utf-8" ), + renderTreePage().toUtf8() ); + } ); + + m_httpServer->route( "/object", + [this]( const QHttpServerRequest& request ) -> QHttpServerResponse + { + const QString path = request.query().queryItemValue( "path" ); + + if ( request.method() == QHttpServerRequest::Method::Post ) + { + if ( caf::PdmObjectHandle* object = resolvePath( path ) ) + { + applyFieldChanges( object, request ); + } + } + + return QHttpServerResponse( QByteArray( "text/html; charset=utf-8" ), + renderObjectPage( path ).toUtf8() ); + } ); + + // Try the preferred port, then fall back to a small range if it is taken. + for ( quint16 candidate = preferredPort; candidate < preferredPort + 20; ++candidate ) + { + quint16 boundPort = m_httpServer->listen( QHostAddress::LocalHost, candidate ); + if ( boundPort != 0 ) + { + m_port = boundPort; + RiaLogging::info( + QString( "HTML project browser started. Open %1 in a web browser." ).arg( url() ).toStdString() ); + return true; + } + } + + RiaLogging::warning( "Failed to start the HTML project browser. No free port found." ); + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +quint16 RiaHtmlServer::port() const +{ + return m_port; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::url() const +{ + return QString( "http://localhost:%1/" ).arg( m_port ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +caf::PdmObjectHandle* RiaHtmlServer::rootObject() +{ + return RimProject::current(); +} + +//-------------------------------------------------------------------------------------------------- +/// Returns the child objects of the given object in field/declaration order. This mirrors the +/// structure of the project data model and gives each child a stable index for addressing. +//-------------------------------------------------------------------------------------------------- +std::vector RiaHtmlServer::orderedChildren( caf::PdmObjectHandle* object ) +{ + std::vector children; + if ( !object ) return children; + + for ( caf::PdmFieldHandle* field : object->fields() ) + { + for ( caf::PdmObjectHandle* child : field->children() ) + { + if ( child ) children.push_back( child ); + } + } + + return children; +} + +//-------------------------------------------------------------------------------------------------- +/// Resolves a dotted path of child indices (e.g. "0.3.1") to an object, starting at the project +/// root. An empty path resolves to the root object. +//-------------------------------------------------------------------------------------------------- +caf::PdmObjectHandle* RiaHtmlServer::resolvePath( const QString& path ) +{ + caf::PdmObjectHandle* current = rootObject(); + if ( !current || path.isEmpty() ) return current; + + const QStringList indices = path.split( '.', Qt::SkipEmptyParts ); + for ( const QString& indexText : indices ) + { + bool ok = false; + const int index = indexText.toInt( &ok ); + + std::vector children = orderedChildren( current ); + if ( !ok || index < 0 || index >= static_cast( children.size() ) ) + { + return nullptr; + } + current = children[index]; + } + + return current; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::renderTreePage() const +{ + caf::PdmObjectHandle* root = rootObject(); + + QString body; + if ( !root ) + { + body = "

No project is currently open.

"; + } + else + { + body = "
    "; + renderTreeNode( root, "", body ); + body += "
"; + } + + return pageShell( "ResInsight Project Tree", body ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaHtmlServer::renderTreeNode( caf::PdmObjectHandle* object, const QString& path, QString& html ) const +{ + if ( !object ) return; + + caf::PdmUiObjectHandle* uiObject = object->uiCapability(); + QString name = uiObject ? uiObject->uiName() : QString(); + if ( name.isEmpty() && object->xmlCapability() ) name = object->xmlCapability()->classKeyword(); + if ( name.isEmpty() ) name = "Object"; + + html += "
  • "; + html += QString( "%2" ).arg( path, htmlEscape( name ) ); + + std::vector children = orderedChildren( object ); + if ( !children.empty() ) + { + html += "
      "; + for ( size_t i = 0; i < children.size(); ++i ) + { + const QString childPath = path.isEmpty() ? QString::number( i ) : QString( "%1.%2" ).arg( path ).arg( i ); + renderTreeNode( children[i], childPath, html ); + } + html += "
    "; + } + + html += "
  • "; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::renderObjectPage( const QString& path ) const +{ + caf::PdmObjectHandle* object = resolvePath( path ); + if ( !object ) + { + return pageShell( "Object not found", + "

    The requested object could not be found.

    " + "

    Back to project tree

    " ); + } + + caf::PdmUiObjectHandle* uiObject = object->uiCapability(); + QString name = uiObject ? uiObject->uiName() : QString(); + QString className = object->xmlCapability() ? object->xmlCapability()->classKeyword() : QString(); + if ( name.isEmpty() ) name = className.isEmpty() ? QString( "Object" ) : className; + + QString body; + body += "

    ← Project tree

    "; + body += QString( "

    %1

    " ).arg( htmlEscape( name ) ); + if ( !className.isEmpty() ) body += QString( "

    %1

    " ).arg( htmlEscape( className ) ); + + body += QString( "
    " ).arg( path ); + body += ""; + body += ""; + + int valueFieldCount = 0; + for ( caf::PdmFieldHandle* field : object->fields() ) + { + auto* valueField = dynamic_cast( field ); + if ( !valueField ) continue; + + valueFieldCount++; + + caf::PdmUiFieldHandle* uiField = field->uiCapability(); + QString fieldName = uiField ? uiField->uiName() : QString(); + if ( fieldName.isEmpty() ) fieldName = field->keyword(); + + const QString keyword = field->keyword(); + const QString value = valueField->toQVariant().toString(); + const bool readOnly = valueField->isReadOnly(); + + body += ""; + body += QString( "" ).arg( htmlEscape( fieldName ) ); + body += QString( "" ).arg( htmlEscape( keyword ) ); + body += QString( "" ) + .arg( htmlEscape( keyword ), htmlEscape( value ), readOnly ? QString( " readonly" ) : QString() ); + body += ""; + } + + body += "
    FieldKeywordValue
    %1%1
    "; + + if ( valueFieldCount > 0 ) + { + body += "

    "; + } + else + { + body += "

    This object has no editable value fields.

    "; + } + body += "
    "; + + std::vector children = orderedChildren( object ); + if ( !children.empty() ) + { + body += "

    Children

      "; + for ( size_t i = 0; i < children.size(); ++i ) + { + const QString childPath = path.isEmpty() ? QString::number( i ) : QString( "%1.%2" ).arg( path ).arg( i ); + + caf::PdmUiObjectHandle* childUi = children[i]->uiCapability(); + QString childName = childUi ? childUi->uiName() : QString(); + if ( childName.isEmpty() ) childName = "Object"; + + body += QString( "
    • %2
    • " ).arg( childPath, htmlEscape( childName ) ); + } + body += "
    "; + } + + return pageShell( name, body ); +} + +//-------------------------------------------------------------------------------------------------- +/// Parses the posted form body and applies submitted values to matching value fields. GUI editors +/// and dependent state are updated for each changed field. +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::applyFieldChanges( caf::PdmObjectHandle* object, const QHttpServerRequest& request ) const +{ + // In application/x-www-form-urlencoded bodies, spaces are encoded as '+'. Translate them to the + // percent form so QUrlQuery decodes them back to spaces (a literal '+' arrives as "%2B"). + QString rawBody = QString::fromUtf8( request.body() ); + rawBody.replace( '+', "%20" ); + const QUrlQuery form( rawBody ); + + int changedCount = 0; + for ( caf::PdmFieldHandle* field : object->fields() ) + { + auto* valueField = dynamic_cast( field ); + if ( !valueField || valueField->isReadOnly() ) continue; + + const QString keyword = field->keyword(); + if ( !form.hasQueryItem( keyword ) ) continue; + + const QString submitted = form.queryItemValue( keyword, QUrl::FullyDecoded ); + if ( submitted.isEmpty() ) continue; + + const QVariant oldValue = valueField->toQVariant(); + + QVariant newValue( submitted ); + if ( oldValue.isValid() && oldValue.typeId() != QMetaType::QString ) + { + QVariant converted = newValue; + if ( converted.convert( oldValue.metaType() ) ) newValue = converted; + } + + if ( newValue == oldValue ) continue; + + valueField->setFromQVariant( newValue ); + if ( object->uiCapability() ) + { + object->uiCapability()->fieldChangedByUi( field, oldValue, newValue ); + } + changedCount++; + } + + if ( changedCount > 0 && object->uiCapability() ) + { + object->uiCapability()->updateConnectedEditors(); + } + + return QString(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::pageShell( const QString& title, const QString& body ) +{ + QString page; + page += ""; + page += QString( "%1" ).arg( htmlEscape( title ) ); + page += ""; + page += body; + page += ""; + return page; +} diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.h b/ApplicationLibCode/HttpServer/RiaHtmlServer.h new file mode 100644 index 0000000000..9122111aeb --- /dev/null +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.h @@ -0,0 +1,72 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight 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 GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include + +#include + +class QHttpServer; +class QHttpServerRequest; + +namespace caf +{ +class PdmObjectHandle; +} + +//================================================================================================== +/// +/// Lightweight HTTP server exposing the ResInsight project tree and a property editor as HTML. +/// +/// Routes: +/// GET / Project tree +/// GET /object?path=... Property editor for the object at the given tree path +/// POST /object?path=... Apply edited field values, then re-render the editor +/// +/// Objects are addressed by a dotted path of child indices from the project root, e.g. "0.3.1". +//================================================================================================== +class RiaHtmlServer : public QObject +{ + Q_OBJECT + +public: + explicit RiaHtmlServer( QObject* parent = nullptr ); + ~RiaHtmlServer() override; + + bool start( quint16 preferredPort = 8080 ); + quint16 port() const; + QString url() const; + +private: + static caf::PdmObjectHandle* rootObject(); + static std::vector orderedChildren( caf::PdmObjectHandle* object ); + static caf::PdmObjectHandle* resolvePath( const QString& path ); + + QString renderTreePage() const; + void renderTreeNode( caf::PdmObjectHandle* object, const QString& path, QString& html ) const; + QString renderObjectPage( const QString& path ) const; + QString applyFieldChanges( caf::PdmObjectHandle* object, const QHttpServerRequest& request ) const; + + static QString pageShell( const QString& title, const QString& body ); + +private: + QHttpServer* m_httpServer; + quint16 m_port; +}; diff --git a/CMakeLists.txt b/CMakeLists.txt index abbef1a644..13891e1e67 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -594,9 +594,10 @@ endif() find_package( Qt6 COMPONENTS - REQUIRED Core Gui OpenGL Network Widgets + REQUIRED Core Gui OpenGL Network Widgets HttpServer ) -set(QT_LIBRARIES Qt6::Core Qt6::Gui Qt6::OpenGL Qt6::Network Qt6::Widgets) +set(QT_LIBRARIES Qt6::Core Qt6::Gui Qt6::OpenGL Qt6::Network Qt6::Widgets + Qt6::HttpServer) qt_standard_project_setup() # Disable use of foreach From 036f3ae3427c30e074bcb71f5e6f1c67a446513d Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sat, 30 May 2026 21:24:50 +0200 Subject: [PATCH 02/12] Add 3D view snapshot and two-pane layout to HTML editor Render the project tree and property editor in a two-pane layout with a collapsible tree, and show the active 3D view as a PNG snapshot served from a new /viewsnapshot endpoint. Scope the Qt6::HttpServer dependency to ApplicationLibCode instead of the top-level CMakeLists. --- .../HttpServer/RiaHtmlServer.cpp | 114 +++++++++++++++--- CMakeLists.txt | 5 +- 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 3f2eb21c14..46f2bb1b7c 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -18,8 +18,10 @@ #include "RiaHtmlServer.h" +#include "RiaApplication.h" #include "RiaLogging.h" +#include "Rim3dView.h" #include "RimProject.h" #include "cafPdmFieldHandle.h" @@ -29,10 +31,13 @@ #include "cafPdmValueField.h" #include "cafPdmXmlObjectHandle.h" +#include +#include #include #include #include #include +#include #include namespace @@ -100,6 +105,31 @@ bool RiaHtmlServer::start( quint16 preferredPort ) renderObjectPage( path ).toUtf8() ); } ); + m_httpServer->route( "/viewsnapshot", + []( const QHttpServerRequest& request ) -> QHttpServerResponse + { + Q_UNUSED( request ); + + Rim3dView* view = RiaApplication::instance()->activeReservoirView(); + if ( !view ) + { + return QHttpServerResponse( QHttpServerResponder::StatusCode::NotFound ); + } + + QImage image = view->snapshotWindowContent(); + if ( image.isNull() ) + { + return QHttpServerResponse( QHttpServerResponder::StatusCode::NotFound ); + } + + QByteArray bytes; + QBuffer buffer( &bytes ); + buffer.open( QIODevice::WriteOnly ); + image.save( &buffer, "PNG" ); + + return QHttpServerResponse( QByteArray( "image/png" ), bytes ); + } ); + // Try the preferred port, then fall back to a small range if it is taken. for ( quint16 candidate = preferredPort; candidate < preferredPort + 20; ++candidate ) { @@ -194,18 +224,27 @@ QString RiaHtmlServer::renderTreePage() const { caf::PdmObjectHandle* root = rootObject(); - QString body; + QString tree; if ( !root ) { - body = "

    No project is currently open.

    "; + tree = "

    No project is currently open.

    "; } else { - body = "
      "; - renderTreeNode( root, "", body ); - body += "
    "; + tree = "
      "; + renderTreeNode( root, "", tree ); + tree += "
    "; } + // Two-pane layout: the collapsible project tree on the left, the property editor for the + // selected node loaded into a separate view (iframe) on the right. + QString body; + body += "
    "; + body += QString( "

    Project tree

    %1
    " ).arg( tree ); + body += ""; + body += "
    "; + return pageShell( "ResInsight Project Tree", body ); } @@ -221,21 +260,28 @@ void RiaHtmlServer::renderTreeNode( caf::PdmObjectHandle* object, const QString& if ( name.isEmpty() && object->xmlCapability() ) name = object->xmlCapability()->classKeyword(); if ( name.isEmpty() ) name = "Object"; - html += "
  • "; - html += QString( "%2" ).arg( path, htmlEscape( name ) ); + const QString link = + QString( "%2" ).arg( path, htmlEscape( name ) ); std::vector children = orderedChildren( object ); - if ( !children.empty() ) + + html += "
  • "; + if ( children.empty() ) { - html += "
      "; + // Leaf node: align with parents that show an expander triangle. + html += QString( "%1" ).arg( link ); + } + else + { + // Expandable node:
      / provides a native expand/collapse triangle. + html += "
      " + link + "
        "; for ( size_t i = 0; i < children.size(); ++i ) { const QString childPath = path.isEmpty() ? QString::number( i ) : QString( "%1.%2" ).arg( path ).arg( i ); renderTreeNode( children[i], childPath, html ); } - html += "
      "; + html += "
    "; } - html += "
  • "; } @@ -248,8 +294,9 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const if ( !object ) { return pageShell( "Object not found", - "

    The requested object could not be found.

    " - "

    Back to project tree

    " ); + "
    " + "

    Select an object in the project tree to edit its properties.

    " + "
    " ); } caf::PdmUiObjectHandle* uiObject = object->uiCapability(); @@ -258,10 +305,13 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const if ( name.isEmpty() ) name = className.isEmpty() ? QString( "Object" ) : className; QString body; - body += "

    ← Project tree

    "; + body += "
    "; body += QString( "

    %1

    " ).arg( htmlEscape( name ) ); if ( !className.isEmpty() ) body += QString( "

    %1

    " ).arg( htmlEscape( className ) ); + // Two columns: properties (and children) on the left, the active 3D view snapshot on the right. + body += "
    "; + body += QString( "
    " ).arg( path ); body += ""; body += ""; @@ -319,6 +369,23 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const body += ""; } + body += ""; // .objmain + + // Active 3D view screenshot in the right column. The cache-busting timestamp forces the browser + // to fetch a fresh image every time this page is (re-)rendered, e.g. after applying a change. + body += "
    "; + if ( RiaApplication::instance()->activeReservoirView() ) + { + body += "

    Active 3D view

    "; + body += QString( "\"Active" ) + .arg( QDateTime::currentMSecsSinceEpoch() ); + } + body += "
    "; // .objside + + body += ""; // .objcols + + body += ""; // .editorpane-body + return pageShell( name, body ); } @@ -382,19 +449,32 @@ QString RiaHtmlServer::pageShell( const QString& title, const QString& body ) page += ""; page += QString( "%1" ).arg( htmlEscape( title ) ); page += ""; page += body; page += ""; diff --git a/CMakeLists.txt b/CMakeLists.txt index 13891e1e67..abbef1a644 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -594,10 +594,9 @@ endif() find_package( Qt6 COMPONENTS - REQUIRED Core Gui OpenGL Network Widgets HttpServer + REQUIRED Core Gui OpenGL Network Widgets ) -set(QT_LIBRARIES Qt6::Core Qt6::Gui Qt6::OpenGL Qt6::Network Qt6::Widgets - Qt6::HttpServer) +set(QT_LIBRARIES Qt6::Core Qt6::Gui Qt6::OpenGL Qt6::Network Qt6::Widgets) qt_standard_project_setup() # Disable use of foreach From 2a729101a7f3b0eebbec22162834c2aea6b455d9 Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sat, 30 May 2026 21:49:35 +0200 Subject: [PATCH 03/12] Add WebGL triangle view with cell-result coloring to HTML server Reuse the VdeVizDataExtractor pipeline (the same one RicHoloLensSession feeds to the HoloLens sharing server) to extract the active grid view's triangle meshes and serve them as JSON. A new /trianglesview page renders them with three.js, embedded alongside the existing snapshot on the object page. Cell-result meshes are colored by passing the color-legend texture and per-vertex texture coordinates to the browser, matching the native 3D view. --- .../HttpServer/RiaHtmlServer.cpp | 317 +++++++++++++++++- ApplicationLibCode/HttpServer/RiaHtmlServer.h | 10 +- 2 files changed, 312 insertions(+), 15 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 46f2bb1b7c..d60a26cfc9 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -22,8 +22,18 @@ #include "RiaLogging.h" #include "Rim3dView.h" +#include "RimGridView.h" #include "RimProject.h" +#include "RifJsonEncodeDecode.h" + +// The triangle extraction reuses the same machinery that RicHoloLensSession uses to ship +// geometry to the HoloLens sharing server. These headers live in the sibling Commands library. +#include "../Commands/HoloLensCommands/VdeArrayDataPacket.h" +#include "../Commands/HoloLensCommands/VdeCachingHashedIdFactory.h" +#include "../Commands/HoloLensCommands/VdePacketDirectory.h" +#include "../Commands/HoloLensCommands/VdeVizDataExtractor.h" + #include "cafPdmFieldHandle.h" #include "cafPdmObjectHandle.h" #include "cafPdmUiFieldHandle.h" @@ -39,6 +49,8 @@ #include #include #include +#include +#include namespace { @@ -54,6 +66,143 @@ QString htmlEscape( const QString& text ) escaped.replace( '"', """ ); return escaped; } + +//-------------------------------------------------------------------------------------------------- +/// Extract the triangle meshes of the active grid view as JSON for the WebGL viewer. +/// +/// This drives the exact same extraction pipeline that RicHoloLensSession uses to feed the +/// HoloLens sharing server: VdeVizDataExtractor produces a meta-data JSON describing each mesh +/// plus a set of binary array packets (vertices and connectivities). Here the browser plays the +/// role of the HoloLens client, so we transcode those packets into a compact JSON payload: +/// +/// { "meshes": [ { "name", "opacity", "vertices":[x,y,z,...], "indices":[i,j,k,...], +/// }, ... ] } +/// +/// is either a solid "color":[r,g,b], or, for cell-result meshes, a color-legend +/// texture: "uv":[u,v,...] plus a base64 RGB image in "texData" with "texWidth"/"texHeight". +/// +/// Only triangle meshes are emitted; line geometry (verticesPerPrimitive == 2) is skipped. +//-------------------------------------------------------------------------------------------------- +QByteArray buildTrianglesJson() +{ + RimGridView* view = RiaApplication::instance()->activeGridView(); + if ( !view ) + { + return QByteArray( "{\"meshes\":[]}" ); + } + + VdeCachingHashedIdFactory idFactory; + VdePacketDirectory packetDirectory; + VdeVizDataExtractor extractor( *view, &idFactory ); + + QString modelMetaJsonStr; + std::vector allReferencedArrayIds; + extractor.extractViewContents( &modelMetaJsonStr, &allReferencedArrayIds, &packetDirectory ); + + const QMap modelMeta = ResInsightInternalJson::Json::decode( modelMetaJsonStr ); + const QVariantList meshList = modelMeta.value( "meshArr" ).toList(); + + QByteArray json; + json.reserve( 1024 * 1024 ); + json += "{\"meshes\":["; + + bool firstMesh = true; + for ( const QVariant& meshVar : meshList ) + { + const QVariantMap mesh = meshVar.toMap(); + + // Triangles only. + if ( mesh.value( "verticesPerPrimitive" ).toInt() != 3 ) continue; + + const VdeArrayDataPacket* vertexPacket = packetDirectory.lookupPacket( mesh.value( "vertexArrId", -1 ).toInt() ); + const VdeArrayDataPacket* connPacket = packetDirectory.lookupPacket( mesh.value( "connArrId", -1 ).toInt() ); + if ( !vertexPacket || !connPacket ) continue; + if ( vertexPacket->elementType() != VdeArrayDataPacket::Float32 ) continue; + if ( connPacket->elementType() != VdeArrayDataPacket::Uint32 ) continue; + + const float opacity = mesh.value( "opacity", 1.0 ).toFloat(); + + // A mesh is either textured (cell results sample a per-vertex texture coordinate into a + // color-legend image) or carries a single solid color. Reproduce both so the WebGL view + // shows the same coloring as the native 3D view. + const VdeArrayDataPacket* texCoordPacket = packetDirectory.lookupPacket( mesh.value( "texCoordsArrId", -1 ).toInt() ); + const VdeArrayDataPacket* texImagePacket = packetDirectory.lookupPacket( mesh.value( "texImageArrId", -1 ).toInt() ); + if ( texCoordPacket && texCoordPacket->elementType() != VdeArrayDataPacket::Float32 ) texCoordPacket = nullptr; + if ( texImagePacket && texImagePacket->elementType() != VdeArrayDataPacket::Uint8 ) texImagePacket = nullptr; + const bool textured = texCoordPacket && texImagePacket; + + if ( !firstMesh ) json += ','; + firstMesh = false; + + QString name = mesh.value( "meshSourceObjName" ).toString(); + name.replace( '\\', "\\\\" ).replace( '"', "\\\"" ); + + json += "{\"name\":\""; + json += name.toUtf8(); + json += "\",\"opacity\":"; + json += QByteArray::number( opacity ); + + if ( textured ) + { + // RGB legend image, base64-encoded. The browser builds a DataTexture directly from + // these bytes (no PNG round-trip), preserving the OpenGL lower-left origin. + const QByteArray rgb( texImagePacket->arrayData(), static_cast( texImagePacket->elementCount() ) ); + json += ",\"texWidth\":" + QByteArray::number( texImagePacket->imageWidth() ); + json += ",\"texHeight\":" + QByteArray::number( texImagePacket->imageHeight() ); + json += ",\"texData\":\"" + rgb.toBase64() + "\""; + + json += ",\"uv\":["; + const float* uv = reinterpret_cast( texCoordPacket->arrayData() ); + const size_t count = texCoordPacket->elementCount(); + for ( size_t i = 0; i < count; i++ ) + { + if ( i ) json += ','; + json += QByteArray::number( uv[i] ); + } + json += "]"; + } + else + { + float r = 0.6f, g = 0.7f, b = 0.85f; + if ( mesh.contains( "color" ) ) + { + const QVariantMap color = mesh.value( "color" ).toMap(); + r = color.value( "r", r ).toFloat(); + g = color.value( "g", g ).toFloat(); + b = color.value( "b", b ).toFloat(); + } + json += ",\"color\":["; + json += QByteArray::number( r ) + ',' + QByteArray::number( g ) + ',' + QByteArray::number( b ); + json += "]"; + } + + json += ",\"vertices\":["; + { + const float* floats = reinterpret_cast( vertexPacket->arrayData() ); + const size_t count = vertexPacket->elementCount(); + for ( size_t i = 0; i < count; i++ ) + { + if ( i ) json += ','; + json += QByteArray::number( floats[i] ); + } + } + + json += "],\"indices\":["; + { + const unsigned int* indices = reinterpret_cast( connPacket->arrayData() ); + const size_t count = connPacket->elementCount(); + for ( size_t i = 0; i < count; i++ ) + { + if ( i ) json += ','; + json += QByteArray::number( indices[i] ); + } + } + json += "]}"; + } + + json += "]}"; + return json; +} } // namespace //-------------------------------------------------------------------------------------------------- @@ -84,8 +233,7 @@ bool RiaHtmlServer::start( quint16 preferredPort ) [this]( const QHttpServerRequest& request ) -> QHttpServerResponse { Q_UNUSED( request ); - return QHttpServerResponse( QByteArray( "text/html; charset=utf-8" ), - renderTreePage().toUtf8() ); + return QHttpServerResponse( QByteArray( "text/html; charset=utf-8" ), renderTreePage().toUtf8() ); } ); m_httpServer->route( "/object", @@ -101,8 +249,7 @@ bool RiaHtmlServer::start( quint16 preferredPort ) } } - return QHttpServerResponse( QByteArray( "text/html; charset=utf-8" ), - renderObjectPage( path ).toUtf8() ); + return QHttpServerResponse( QByteArray( "text/html; charset=utf-8" ), renderObjectPage( path ).toUtf8() ); } ); m_httpServer->route( "/viewsnapshot", @@ -130,6 +277,20 @@ bool RiaHtmlServer::start( quint16 preferredPort ) return QHttpServerResponse( QByteArray( "image/png" ), bytes ); } ); + m_httpServer->route( "/trianglesview", + [this]( const QHttpServerRequest& request ) -> QHttpServerResponse + { + Q_UNUSED( request ); + return QHttpServerResponse( QByteArray( "text/html; charset=utf-8" ), renderTrianglesPage().toUtf8() ); + } ); + + m_httpServer->route( "/triangles", + []( const QHttpServerRequest& request ) -> QHttpServerResponse + { + Q_UNUSED( request ); + return QHttpServerResponse( QByteArray( "application/json" ), buildTrianglesJson() ); + } ); + // Try the preferred port, then fall back to a small range if it is taken. for ( quint16 candidate = preferredPort; candidate < preferredPort + 20; ++candidate ) { @@ -137,8 +298,7 @@ bool RiaHtmlServer::start( quint16 preferredPort ) if ( boundPort != 0 ) { m_port = boundPort; - RiaLogging::info( - QString( "HTML project browser started. Open %1 in a web browser." ).arg( url() ).toStdString() ); + RiaLogging::info( QString( "HTML project browser started. Open %1 in a web browser." ).arg( url() ).toStdString() ); return true; } } @@ -240,7 +400,10 @@ QString RiaHtmlServer::renderTreePage() const // selected node loaded into a separate view (iframe) on the right. QString body; body += "
    "; - body += QString( "

    Project tree

    %1
    " ).arg( tree ); + body += "

    Project tree

    "; + body += "

    Open 3D triangle view →

    "; + body += tree; + body += "
    "; body += ""; body += "
    "; @@ -260,8 +423,7 @@ void RiaHtmlServer::renderTreeNode( caf::PdmObjectHandle* object, const QString& if ( name.isEmpty() && object->xmlCapability() ) name = object->xmlCapability()->classKeyword(); if ( name.isEmpty() ) name = "Object"; - const QString link = - QString( "%2" ).arg( path, htmlEscape( name ) ); + const QString link = QString( "%2" ).arg( path, htmlEscape( name ) ); std::vector children = orderedChildren( object ); @@ -371,13 +533,20 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const body += ""; // .objmain - // Active 3D view screenshot in the right column. The cache-busting timestamp forces the browser - // to fetch a fresh image every time this page is (re-)rendered, e.g. after applying a change. + // Right column: a static screenshot of the active 3D view and, below it, an interactive WebGL + // view of the same triangle geometry. The cache-busting timestamp forces the browser to fetch a + // fresh image every time this page is (re-)rendered, e.g. after applying a change. body += "
    "; if ( RiaApplication::instance()->activeReservoirView() ) { body += "

    Active 3D view

    "; - body += QString( "\"Active" ) + body += + QString( "\"Active" ).arg( QDateTime::currentMSecsSinceEpoch() ); + } + if ( RiaApplication::instance()->activeGridView() ) + { + body += "

    3D triangle view

    "; + body += QString( "" ) .arg( QDateTime::currentMSecsSinceEpoch() ); } body += "
    "; // .objside @@ -440,6 +609,129 @@ QString RiaHtmlServer::applyFieldChanges( caf::PdmObjectHandle* object, const QH return QString(); } +//-------------------------------------------------------------------------------------------------- +/// Self-contained WebGL page that fetches the active view's triangle meshes from /triangles and +/// renders them with three.js (loaded from a CDN). Drag to orbit, scroll to zoom. +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::renderTrianglesPage() const +{ + return QString::fromUtf8( + R"HTMLPAGE( + + + +ResInsight 3D triangle view + + + + +
    Loading geometry…
    + + +)HTMLPAGE" ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -475,6 +767,7 @@ QString RiaHtmlServer::pageShell( const QString& title, const QString& body ) ".objside{flex:1 1 24em;min-width:0;}" ".objside h3{margin-top:0;}" ".viewshot{max-width:100%;border:1px solid #ccc;margin-top:0.5em;}" + ".viewframe{width:100%;height:24em;border:1px solid #ccc;margin-top:0.5em;}" ""; page += body; page += ""; diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.h b/ApplicationLibCode/HttpServer/RiaHtmlServer.h index 9122111aeb..d673b40f7d 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.h +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.h @@ -39,6 +39,9 @@ class PdmObjectHandle; /// GET / Project tree /// GET /object?path=... Property editor for the object at the given tree path /// POST /object?path=... Apply edited field values, then re-render the editor +/// GET /viewsnapshot PNG snapshot of the active 3D view +/// GET /trianglesview WebGL page rendering the active view's triangle meshes +/// GET /triangles Triangle meshes of the active grid view as JSON /// /// Objects are addressed by a dotted path of child indices from the project root, e.g. "0.3.1". //================================================================================================== @@ -55,14 +58,15 @@ class RiaHtmlServer : public QObject QString url() const; private: - static caf::PdmObjectHandle* rootObject(); - static std::vector orderedChildren( caf::PdmObjectHandle* object ); - static caf::PdmObjectHandle* resolvePath( const QString& path ); + static caf::PdmObjectHandle* rootObject(); + static std::vector orderedChildren( caf::PdmObjectHandle* object ); + static caf::PdmObjectHandle* resolvePath( const QString& path ); QString renderTreePage() const; void renderTreeNode( caf::PdmObjectHandle* object, const QString& path, QString& html ) const; QString renderObjectPage( const QString& path ) const; QString applyFieldChanges( caf::PdmObjectHandle* object, const QHttpServerRequest& request ) const; + QString renderTrianglesPage() const; static QString pageShell( const QString& title, const QString& body ); From 3ecf6fb80d964c92602ff5bb241da300c32955ab Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sat, 30 May 2026 22:00:56 +0200 Subject: [PATCH 04/12] Auto-refresh HTML view when the native 3D view changes Add view-state version counters to the HTML server: one bumped on camera navigation (RiuViewer::navigationPolicyUpdate) and one bumped when the display model is rebuilt (Rim3dView), i.e. when the visible cell set changes via filters or time step. A new /viewstate endpoint exposes both. The object page polls it and reloads the snapshot on any change, while the triangle view refetches and rebuilds its geometry only on visible-cell changes, preserving its own orbit camera during plain navigation. --- .../HttpServer/RiaHtmlServer.cpp | 152 +++++++++++++----- ApplicationLibCode/HttpServer/RiaHtmlServer.h | 11 ++ .../ProjectDataModel/Rim3dView.cpp | 9 ++ .../UserInterface/RiuViewer.cpp | 4 + 4 files changed, 135 insertions(+), 41 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index d60a26cfc9..3b5abb06d8 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -205,6 +205,9 @@ QByteArray buildTrianglesJson() } } // namespace +std::atomic RiaHtmlServer::sm_viewVersion{ 0 }; +std::atomic RiaHtmlServer::sm_geometryVersion{ 0 }; + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -215,6 +218,22 @@ RiaHtmlServer::RiaHtmlServer( QObject* parent ) { } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaHtmlServer::notifyViewChanged() +{ + sm_viewVersion.fetch_add( 1, std::memory_order_relaxed ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaHtmlServer::notifyGeometryChanged() +{ + sm_geometryVersion.fetch_add( 1, std::memory_order_relaxed ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -291,6 +310,17 @@ bool RiaHtmlServer::start( quint16 preferredPort ) return QHttpServerResponse( QByteArray( "application/json" ), buildTrianglesJson() ); } ); + m_httpServer->route( "/viewstate", + []( const QHttpServerRequest& request ) -> QHttpServerResponse + { + Q_UNUSED( request ); + const quint64 view = sm_viewVersion.load( std::memory_order_relaxed ); + const quint64 geometry = sm_geometryVersion.load( std::memory_order_relaxed ); + const QByteArray json = "{\"view\":" + QByteArray::number( view ) + + ",\"geometry\":" + QByteArray::number( geometry ) + "}"; + return QHttpServerResponse( QByteArray( "application/json" ), json ); + } ); + // Try the preferred port, then fall back to a small range if it is taken. for ( quint16 candidate = preferredPort; candidate < preferredPort + 20; ++candidate ) { @@ -555,6 +585,18 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const body += ""; // .editorpane-body + // Poll the view-state versions and reload the snapshot whenever the native 3D view changes in + // the desktop app, whether from camera navigation or a visible-cell change. The embedded + // triangle view refreshes itself (on geometry changes only), so it is left untouched here. + body += ""; + return pageShell( name, body ); } @@ -675,49 +717,77 @@ function frameToBox(box) { controls.update(); } -fetch('/triangles').then(r => r.json()).then(data => { - const meshes = data.meshes || []; - let triCount = 0; - for (const m of meshes) { - const geom = new THREE.BufferGeometry(); - geom.setAttribute('position', new THREE.Float32BufferAttribute(m.vertices, 3)); - geom.setIndex(m.indices); - geom.computeVertexNormals(); - const opacity = (m.opacity === undefined) ? 1 : m.opacity; - const params = { transparent: opacity < 1, opacity: opacity, side: THREE.DoubleSide }; - if (m.texData) { - // Cell-result coloring: sample the color-legend image through per-vertex UVs. The legend is - // RGB; expand to RGBA for a DataTexture, keeping the OpenGL lower-left origin (flipY = false). - const rgb = Uint8Array.from(atob(m.texData), ch => ch.charCodeAt(0)); - const pixelCount = m.texWidth * m.texHeight; - const rgba = new Uint8Array(pixelCount * 4); - for (let i = 0; i < pixelCount; i++) { - rgba[i*4] = rgb[i*3]; rgba[i*4+1] = rgb[i*3+1]; rgba[i*4+2] = rgb[i*3+2]; rgba[i*4+3] = 255; +function clearGroup() { + for (const child of group.children) { + child.geometry.dispose(); + if (child.material.map) child.material.map.dispose(); + child.material.dispose(); + } + group.clear(); + group.position.set(0, 0, 0); +} + +let framed = false; // frame the camera only on the first load; keep the user's view afterwards + +function loadGeometry() { + return fetch('/triangles').then(r => r.json()).then(data => { + clearGroup(); + const meshes = data.meshes || []; + let triCount = 0; + for (const m of meshes) { + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(m.vertices, 3)); + geom.setIndex(m.indices); + geom.computeVertexNormals(); + const opacity = (m.opacity === undefined) ? 1 : m.opacity; + const params = { transparent: opacity < 1, opacity: opacity, side: THREE.DoubleSide }; + if (m.texData) { + // Cell-result coloring: sample the color-legend image through per-vertex UVs. The legend is + // RGB; expand to RGBA for a DataTexture, keeping the OpenGL lower-left origin (flipY = false). + const rgb = Uint8Array.from(atob(m.texData), ch => ch.charCodeAt(0)); + const pixelCount = m.texWidth * m.texHeight; + const rgba = new Uint8Array(pixelCount * 4); + for (let i = 0; i < pixelCount; i++) { + rgba[i*4] = rgb[i*3]; rgba[i*4+1] = rgb[i*3+1]; rgba[i*4+2] = rgb[i*3+2]; rgba[i*4+3] = 255; + } + const tex = new THREE.DataTexture(rgba, m.texWidth, m.texHeight, THREE.RGBAFormat); + tex.flipY = false; + tex.minFilter = THREE.LinearFilter; + tex.magFilter = THREE.LinearFilter; + tex.wrapS = THREE.ClampToEdgeWrapping; + tex.wrapT = THREE.ClampToEdgeWrapping; + tex.needsUpdate = true; + geom.setAttribute('uv', new THREE.Float32BufferAttribute(m.uv, 2)); + params.map = tex; + } else { + const c = m.color || [0.7, 0.7, 0.7]; + params.color = new THREE.Color(c[0], c[1], c[2]); } - const tex = new THREE.DataTexture(rgba, m.texWidth, m.texHeight, THREE.RGBAFormat); - tex.flipY = false; - tex.minFilter = THREE.LinearFilter; - tex.magFilter = THREE.LinearFilter; - tex.wrapS = THREE.ClampToEdgeWrapping; - tex.wrapT = THREE.ClampToEdgeWrapping; - tex.needsUpdate = true; - geom.setAttribute('uv', new THREE.Float32BufferAttribute(m.uv, 2)); - params.map = tex; - } else { - const c = m.color || [0.7, 0.7, 0.7]; - params.color = new THREE.Color(c[0], c[1], c[2]); + const mat = new THREE.MeshLambertMaterial(params); + group.add(new THREE.Mesh(geom, mat)); + triCount += m.indices.length / 3; } - const mat = new THREE.MeshLambertMaterial(params); - group.add(new THREE.Mesh(geom, mat)); - triCount += m.indices.length / 3; - } - if (meshes.length === 0) { - info.innerHTML = 'No active 3D view with triangle geometry. Back to project tree'; - return; - } - frameToBox(new THREE.Box3().setFromObject(group)); - info.innerHTML = meshes.length + ' mesh(es), ' + triCount + ' triangles. Drag to orbit, scroll to zoom. Back'; -}).catch(e => { info.textContent = 'Failed to load geometry: ' + e; }); + if (meshes.length === 0) { + info.innerHTML = 'No active 3D view with triangle geometry. Back to project tree'; + return; + } + if (!framed) { frameToBox(new THREE.Box3().setFromObject(group)); framed = true; } + info.innerHTML = meshes.length + ' mesh(es), ' + triCount + ' triangles. Drag to orbit, scroll to zoom. Back'; + }).catch(e => { info.textContent = 'Failed to load geometry: ' + e; }); +} + +loadGeometry(); + +// Refetch the geometry whenever the visible cells change in the native 3D view (filters, time +// step, etc.). Pure camera navigation does not bump the geometry version, so the orbit view is +// preserved. +let lastGeometry = null; +setInterval(() => { + fetch('/viewstate', { cache: 'no-store' }).then(r => r.json()).then(d => { + if (lastGeometry !== null && d.geometry !== lastGeometry) loadGeometry(); + lastGeometry = d.geometry; + }).catch(() => {}); +}, 750); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.h b/ApplicationLibCode/HttpServer/RiaHtmlServer.h index d673b40f7d..399b7dfc83 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.h +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.h @@ -21,6 +21,7 @@ #include #include +#include #include class QHttpServer; @@ -42,6 +43,7 @@ class PdmObjectHandle; /// GET /viewsnapshot PNG snapshot of the active 3D view /// GET /trianglesview WebGL page rendering the active view's triangle meshes /// GET /triangles Triangle meshes of the active grid view as JSON +/// GET /viewstate Version counters {view, geometry} for camera and visible-cell changes /// /// Objects are addressed by a dotted path of child indices from the project root, e.g. "0.3.1". //================================================================================================== @@ -57,6 +59,12 @@ class RiaHtmlServer : public QObject quint16 port() const; QString url() const; + // Bump the version counters so polling web pages know to refresh. Safe to call even when no + // server is running. notifyViewChanged() is for camera navigation (refresh the snapshot); + // notifyGeometryChanged() is for visible-cell changes (also refetch the triangle geometry). + static void notifyViewChanged(); + static void notifyGeometryChanged(); + private: static caf::PdmObjectHandle* rootObject(); static std::vector orderedChildren( caf::PdmObjectHandle* object ); @@ -73,4 +81,7 @@ class RiaHtmlServer : public QObject private: QHttpServer* m_httpServer; quint16 m_port; + + static std::atomic sm_viewVersion; + static std::atomic sm_geometryVersion; }; diff --git a/ApplicationLibCode/ProjectDataModel/Rim3dView.cpp b/ApplicationLibCode/ProjectDataModel/Rim3dView.cpp index 242ca265d5..27b0c072a4 100644 --- a/ApplicationLibCode/ProjectDataModel/Rim3dView.cpp +++ b/ApplicationLibCode/ProjectDataModel/Rim3dView.cpp @@ -21,6 +21,7 @@ #include "RiaApplication.h" #include "RiaFieldHandleTools.h" +#include "RiaHtmlServer.h" #include "RiaOptionItemFactory.h" #include "RiaPreferences.h" #include "RiaPreferencesSystem.h" @@ -702,6 +703,10 @@ void Rim3dView::updateDisplayModelForCurrentTimeStepAndRedraw() m_isCallingUpdateDisplayModelForCurrentTimestepAndRedraw = false; RimMainPlotCollection::current()->updateCurrentTimeStepInPlots(); + + // The current time step may change which cells are visible; let polling web pages refetch the + // triangle geometry. + RiaHtmlServer::notifyGeometryChanged(); } //-------------------------------------------------------------------------------------------------- @@ -758,6 +763,10 @@ void Rim3dView::createDisplayModelAndRedraw() { RiuMainWindow::instance()->refreshAnimationActions(); } + + // The display model (and thus the set of visible cells) was rebuilt; let polling web pages + // refetch the triangle geometry. + RiaHtmlServer::notifyGeometryChanged(); } //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/UserInterface/RiuViewer.cpp b/ApplicationLibCode/UserInterface/RiuViewer.cpp index f5e057d272..b49c229e6f 100644 --- a/ApplicationLibCode/UserInterface/RiuViewer.cpp +++ b/ApplicationLibCode/UserInterface/RiuViewer.cpp @@ -24,6 +24,7 @@ #include "RiaBaseDefs.h" #include "RiaColorTools.h" #include "RiaGuiApplication.h" +#include "RiaHtmlServer.h" #include "RiaPreferences.h" #include "RiaRegressionTestRunner.h" @@ -1022,6 +1023,9 @@ void RiuViewer::navigationPolicyUpdate() { caf::Viewer::navigationPolicyUpdate(); ownerViewWindow()->viewNavigationChanged(); + + // Let polling web pages know the view changed so they can refresh the snapshot. + RiaHtmlServer::notifyViewChanged(); if ( m_rimView ) { RimViewLinker* viewLinker = m_rimView->assosiatedViewLinker(); From 09f5cca1b83023290ac8a7905d3f522ff47ce074 Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sat, 30 May 2026 22:05:59 +0200 Subject: [PATCH 05/12] Collapse HTML project tree by default, expanding to the active view Tree nodes start collapsed. The root node and the chain of nodes leading to the active 3D view are open by default so that view is revealed on load. --- .../HttpServer/RiaHtmlServer.cpp | 24 +++++++++++++++++-- ApplicationLibCode/HttpServer/RiaHtmlServer.h | 1 + 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 3b5abb06d8..74f17df876 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -381,6 +381,21 @@ std::vector RiaHtmlServer::orderedChildren( caf::PdmObjec return children; } +//-------------------------------------------------------------------------------------------------- +/// Returns true if target is object itself or any descendant of it. +//-------------------------------------------------------------------------------------------------- +bool RiaHtmlServer::subtreeContainsObject( caf::PdmObjectHandle* object, caf::PdmObjectHandle* target ) +{ + if ( !object ) return false; + if ( object == target ) return true; + + for ( caf::PdmObjectHandle* child : orderedChildren( object ) ) + { + if ( subtreeContainsObject( child, target ) ) return true; + } + return false; +} + //-------------------------------------------------------------------------------------------------- /// Resolves a dotted path of child indices (e.g. "0.3.1") to an object, starting at the project /// root. An empty path resolves to the root object. @@ -465,8 +480,13 @@ void RiaHtmlServer::renderTreeNode( caf::PdmObjectHandle* object, const QString& } else { - // Expandable node:
    / provides a native expand/collapse triangle. - html += "
    " + link + "
      "; + // Expandable node:
      / provides a native expand/collapse triangle. The root + // node and the chain of nodes leading to the active 3D view are open by default so that view + // is revealed; all other nodes start collapsed. + caf::PdmObjectHandle* activeView = RiaApplication::instance()->activeReservoirView(); + const bool onActivePath = activeView && subtreeContainsObject( object, activeView ); + const QString openAttr = ( path.isEmpty() || onActivePath ) ? " open" : QString(); + html += "" + link + "
        "; for ( size_t i = 0; i < children.size(); ++i ) { const QString childPath = path.isEmpty() ? QString::number( i ) : QString( "%1.%2" ).arg( path ).arg( i ); diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.h b/ApplicationLibCode/HttpServer/RiaHtmlServer.h index 399b7dfc83..ee450414d5 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.h +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.h @@ -68,6 +68,7 @@ class RiaHtmlServer : public QObject private: static caf::PdmObjectHandle* rootObject(); static std::vector orderedChildren( caf::PdmObjectHandle* object ); + static bool subtreeContainsObject( caf::PdmObjectHandle* object, caf::PdmObjectHandle* target ); static caf::PdmObjectHandle* resolvePath( const QString& path ); QString renderTreePage() const; From 0ebab745b6dbaa25bb7f215c77a6e61c26992816 Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sat, 30 May 2026 22:21:58 +0200 Subject: [PATCH 06/12] Add boolean/dropdown field editors to HTML property editor Render boolean fields as checkboxes and option fields as drop-downs, applying edits through PdmUiCommandSystemProxy so enums and notifications work like the desktop editors. Pointer fields are shown read-only and never modified. The WebGL triangle view keeps its rotation, zoom and panning when the geometry reloads after a visible-cell change. --- .../HttpServer/RiaHtmlServer.cpp | 121 +++++++++++++++--- 1 file changed, 102 insertions(+), 19 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 74f17df876..527a14db00 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -36,6 +36,9 @@ #include "cafPdmFieldHandle.h" #include "cafPdmObjectHandle.h" +#include "cafPdmOptionItemInfo.h" +#include "cafPdmPointer.h" +#include "cafPdmUiCommandSystemProxy.h" #include "cafPdmUiFieldHandle.h" #include "cafPdmUiObjectHandle.h" #include "cafPdmValueField.h" @@ -67,6 +70,16 @@ QString htmlEscape( const QString& text ) return escaped; } +//-------------------------------------------------------------------------------------------------- +/// A pointer field (caf::PdmPtrField) references another object. Its value is a guarded pointer, so +/// its QVariant always wraps a caf::PdmPointer (even when null). Such fields +/// are shown read-only and never modified from the HTML editor. +//-------------------------------------------------------------------------------------------------- +bool isPointerField( caf::PdmValueField* valueField ) +{ + return valueField && valueField->toQVariant().userType() == qMetaTypeId>(); +} + //-------------------------------------------------------------------------------------------------- /// Extract the triangle meshes of the active grid view as JSON for the WebGL viewer. /// @@ -544,11 +557,55 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const const QString value = valueField->toQVariant().toString(); const bool readOnly = valueField->isReadOnly(); + // Pick an editor based on the field: pointer fields are shown read-only (never editable), a + // drop-down for fields with selectable options, a checkbox for booleans, otherwise a plain + // text input. + const QList options = ( uiField && !isPointerField( valueField ) ) ? uiField->valueOptions() + : QList(); + + QString editor; + if ( isPointerField( valueField ) ) + { + // Show the referenced object's name as static text; pointer references are not editable. + QString refName; + const std::vector referenced = field->ptrReferencedObjects(); + if ( !referenced.empty() && referenced.front() && referenced.front()->uiCapability() ) + { + refName = referenced.front()->uiCapability()->uiName(); + } + if ( refName.isEmpty() ) refName = "(none)"; + editor = QString( "%1" ).arg( htmlEscape( refName ) ); + } + else if ( !options.isEmpty() ) + { + const int selectedIndex = uiField->uiValue().toInt(); + editor = QString( ""; + } + else if ( valueField->toQVariant().typeId() == QMetaType::Bool ) + { + editor = QString( "" ) + .arg( htmlEscape( keyword ), + valueField->toQVariant().toBool() ? QString( " checked" ) : QString(), + readOnly ? QString( " disabled" ) : QString() ); + } + else + { + editor = QString( "" ) + .arg( htmlEscape( keyword ), htmlEscape( value ), readOnly ? QString( " readonly" ) : QString() ); + } + body += "
    "; body += QString( "" ).arg( htmlEscape( fieldName ) ); body += QString( "" ).arg( htmlEscape( keyword ) ); - body += QString( "" ) - .arg( htmlEscape( keyword ), htmlEscape( value ), readOnly ? QString( " readonly" ) : QString() ); + body += QString( "" ).arg( editor ); body += ""; } @@ -638,28 +695,52 @@ QString RiaHtmlServer::applyFieldChanges( caf::PdmObjectHandle* object, const QH auto* valueField = dynamic_cast( field ); if ( !valueField || valueField->isReadOnly() ) continue; - const QString keyword = field->keyword(); - if ( !form.hasQueryItem( keyword ) ) continue; + // Pointer fields reference other objects and are never modified from the HTML editor. + if ( isPointerField( valueField ) ) continue; - const QString submitted = form.queryItemValue( keyword, QUrl::FullyDecoded ); - if ( submitted.isEmpty() ) continue; + caf::PdmUiFieldHandle* uiField = field->uiCapability(); + if ( !uiField ) continue; - const QVariant oldValue = valueField->toQVariant(); + const QString keyword = field->keyword(); + + // The UI value is what the editor submits: an index into the options for a drop-down, the + // checked state for a checkbox, or the real value otherwise. setUiValueToField() converts + // it back to the stored value and notifies the data model exactly like the desktop editors. + const QList options = uiField->valueOptions(); + const QVariant oldUiValue = uiField->uiValue(); - QVariant newValue( submitted ); - if ( oldValue.isValid() && oldValue.typeId() != QMetaType::QString ) + QVariant newUiValue; + if ( !options.isEmpty() ) { - QVariant converted = newValue; - if ( converted.convert( oldValue.metaType() ) ) newValue = converted; + if ( !form.hasQueryItem( keyword ) ) continue; + bool ok = false; + const int index = form.queryItemValue( keyword ).toInt( &ok ); + if ( !ok || index < 0 || index >= options.size() ) continue; + newUiValue = QVariant( index ); } - - if ( newValue == oldValue ) continue; - - valueField->setFromQVariant( newValue ); - if ( object->uiCapability() ) + else if ( valueField->toQVariant().typeId() == QMetaType::Bool ) { - object->uiCapability()->fieldChangedByUi( field, oldValue, newValue ); + // An unchecked checkbox submits nothing, so presence of the keyword means "checked". + newUiValue = QVariant( form.hasQueryItem( keyword ) ); } + else + { + if ( !form.hasQueryItem( keyword ) ) continue; + const QString submitted = form.queryItemValue( keyword, QUrl::FullyDecoded ); + if ( submitted.isEmpty() ) continue; + + QVariant converted( submitted ); + if ( oldUiValue.isValid() && oldUiValue.typeId() != QMetaType::QString ) + { + QVariant tmp = converted; + if ( tmp.convert( oldUiValue.metaType() ) ) converted = tmp; + } + newUiValue = converted; + } + + if ( newUiValue == oldUiValue ) continue; + + caf::PdmUiCommandSystemProxy::instance()->setUiValueToField( uiField, newUiValue ); changedCount++; } @@ -744,7 +825,8 @@ function clearGroup() { child.material.dispose(); } group.clear(); - group.position.set(0, 0, 0); + // Keep group.position (the recentering offset) so the camera, rotation, zoom and panning are + // preserved across geometry reloads. Only the very first load frames the view. } let framed = false; // frame the camera only on the first load; keep the user's view afterwards @@ -850,7 +932,8 @@ QString RiaHtmlServer::pageShell( const QString& title, const QString& body ) "table.props th,table.props td{border:1px solid #ddd;padding:4px 8px;text-align:left;}" "table.props th{background:#f3f3f3;}" ".keyword{color:#888;font-family:Consolas,monospace;font-size:0.85em;}" - "input[type=text]{min-width:18em;}" + ".ptrref{color:#555;font-style:italic;}" + "input[type=text],select{min-width:18em;}" "button{padding:5px 14px;}" ".objcols{display:flex;gap:1.5em;align-items:flex-start;flex-wrap:wrap;}" ".objmain{flex:1 1 28em;min-width:0;}" From 836bb0650eb4b09cf9f8779b9955cb6f077dc350 Mon Sep 17 00:00:00 2001 From: magnesj <1793152+magnesj@users.noreply.github.com> Date: Sat, 30 May 2026 20:24:26 +0000 Subject: [PATCH 07/12] Fixes by clang-format --- ApplicationLibCode/Application/RiaGuiApplication.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ApplicationLibCode/Application/RiaGuiApplication.cpp b/ApplicationLibCode/Application/RiaGuiApplication.cpp index edca24a407..5787811418 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.cpp +++ b/ApplicationLibCode/Application/RiaGuiApplication.cpp @@ -27,13 +27,13 @@ #include "RiaFileLogger.h" #include "RiaFilePathTools.h" #include "RiaFontCache.h" +#include "RiaHtmlServer.h" #include "RiaImportEclipseCaseTools.h" #include "RiaLogging.h" #include "RiaOpenTelemetryManager.h" #include "RiaPlotWindowRedrawScheduler.h" #include "RiaPreferences.h" #include "RiaPreferencesGrid.h" -#include "RiaHtmlServer.h" #include "RiaPreferencesSystem.h" #include "RiaProjectModifier.h" #include "RiaQStringFormatter.h" From 92773aa52deddee82d539e6539e4fb3cca64d458 Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sun, 31 May 2026 09:12:21 +0200 Subject: [PATCH 08/12] Drop ../Commands prefix from HoloLensCommands includes The Commands directory is already on the ApplicationLibCode include path, so HoloLensCommands/... resolves directly. --- ApplicationLibCode/HttpServer/RiaHtmlServer.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 527a14db00..0c57b226aa 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -29,10 +29,10 @@ // The triangle extraction reuses the same machinery that RicHoloLensSession uses to ship // geometry to the HoloLens sharing server. These headers live in the sibling Commands library. -#include "../Commands/HoloLensCommands/VdeArrayDataPacket.h" -#include "../Commands/HoloLensCommands/VdeCachingHashedIdFactory.h" -#include "../Commands/HoloLensCommands/VdePacketDirectory.h" -#include "../Commands/HoloLensCommands/VdeVizDataExtractor.h" +#include "HoloLensCommands/VdeArrayDataPacket.h" +#include "HoloLensCommands/VdeCachingHashedIdFactory.h" +#include "HoloLensCommands/VdePacketDirectory.h" +#include "HoloLensCommands/VdeVizDataExtractor.h" #include "cafPdmFieldHandle.h" #include "cafPdmObjectHandle.h" From f176e281cf5fc4c31407fd9b8daf6861fd463e8a Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sun, 31 May 2026 09:22:51 +0200 Subject: [PATCH 09/12] Style HTML browser with ResInsight dark theme, title bar and app icon Apply the colors from the dark theme to the generated pages, add a header bar with the application logo and project name, set the page title, and serve the application icon at /appicon.png for use as the favicon. --- .../HttpServer/RiaHtmlServer.cpp | 79 ++++++++++++++----- 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 0c57b226aa..4fc32b64d7 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -46,6 +46,8 @@ #include #include +#include +#include #include #include #include @@ -323,6 +325,18 @@ bool RiaHtmlServer::start( quint16 preferredPort ) return QHttpServerResponse( QByteArray( "application/json" ), buildTrianglesJson() ); } ); + m_httpServer->route( "/appicon.png", + []( const QHttpServerRequest& request ) -> QHttpServerResponse + { + Q_UNUSED( request ); + QFile iconFile( ":/AppLogo48x48.png" ); + if ( !iconFile.open( QIODevice::ReadOnly ) ) + { + return QHttpServerResponse( QHttpServerResponder::StatusCode::NotFound ); + } + return QHttpServerResponse( QByteArray( "image/png" ), iconFile.readAll() ); + } ); + m_httpServer->route( "/viewstate", []( const QHttpServerRequest& request ) -> QHttpServerResponse { @@ -454,9 +468,24 @@ QString RiaHtmlServer::renderTreePage() const tree += ""; } + // Title bar with the application icon and, when a project is open, its file name. + QString projectName; + if ( RimProject* proj = RiaApplication::instance()->project() ) + { + if ( !proj->fileName().isEmpty() ) projectName = QFileInfo( proj->fileName() ).fileName(); + } + + QString header = "
    "; + header += "\"\""; + header += "ResInsight"; + if ( !projectName.isEmpty() ) header += QString( " — %1" ).arg( htmlEscape( projectName ) ); + header += "
    "; + // Two-pane layout: the collapsible project tree on the left, the property editor for the // selected node loaded into a separate view (iframe) on the right. QString body; + body += "
    "; + body += header; body += "
    "; body += "

    Project tree

    "; body += "

    Open 3D triangle view →

    "; @@ -464,9 +493,10 @@ QString RiaHtmlServer::renderTreePage() const body += "
    "; body += ""; - body += "
    "; + body += "
    "; // .layout + body += ""; // .appshell - return pageShell( "ResInsight Project Tree", body ); + return pageShell( "ResInsight Project Browser", body ); } //-------------------------------------------------------------------------------------------------- @@ -764,8 +794,9 @@ QString RiaHtmlServer::renderTrianglesPage() const ResInsight 3D triangle view + "; page += body; page += ""; From 4d0271a28825afeadc7929024c862ceded85450f Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Sun, 31 May 2026 09:34:44 +0200 Subject: [PATCH 10/12] Use filterable list boxes for fields with many options Render option fields as a native " ).arg( htmlEscape( keyword ), readOnly ? QString( " disabled" ) : QString() ); + // Read uiValue() after valueOptions() above so the option cache is populated. Short + // option lists render as a plain drop-down. Long lists get a filter box plus a list box + // (a sized " ) + .arg( htmlEscape( selectId ) ); + selectAttr += " size=\"10\""; + } + editor += QString( " (the selected option is always kept visible). + body += ""; + // Poll the view-state versions and reload the snapshot whenever the native 3D view changes in // the desktop app, whether from camera navigation or a visible-cell change. The embedded // triangle view refreshes itself (on geometry changes only), so it is left untouched here. @@ -742,11 +766,13 @@ QString RiaHtmlServer::applyFieldChanges( caf::PdmObjectHandle* object, const QH QVariant newUiValue; if ( !options.isEmpty() ) { + // The drop-down submits the selected option index. It must be a UInt for the option-based + // field path to recognize it as an index (an int is treated as a raw value instead). if ( !form.hasQueryItem( keyword ) ) continue; bool ok = false; const int index = form.queryItemValue( keyword ).toInt( &ok ); if ( !ok || index < 0 || index >= options.size() ) continue; - newUiValue = QVariant( index ); + newUiValue = QVariant( static_cast( index ) ); } else if ( valueField->toQVariant().typeId() == QMetaType::Bool ) { @@ -976,6 +1002,7 @@ QString RiaHtmlServer::pageShell( const QString& title, const QString& body ) ".ptrref{color:#adbac6;font-style:italic;}" "input[type=text],select{min-width:18em;background:#394046;color:#e6e7ea;" "border:1px solid #5a6067;padding:3px 5px;}" + ".optfilter{display:block;margin-bottom:3px;}" "button{padding:5px 14px;background:#0a639d;color:#fff;border:0;border-radius:3px;cursor:pointer;}" "button:hover{background:#136fa3;}" ".objcols{display:flex;gap:1.5em;align-items:flex-start;flex-wrap:wrap;}" From 7d90c2fe2bccea4c0ed4d1d86ec6616816451ac1 Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Mon, 1 Jun 2026 06:52:50 +0200 Subject: [PATCH 11/12] Build HTML project tree from the UI tree ordering Use caf's UI tree ordering (defineUiTreeOrdering) with the MainWindow.ProjectTree config name to build the tree, so it mirrors the desktop project tree. This makes views appear under their grid model and honors custom ordering, grouping and hidden items. Nodes are addressed by their index path in the ordering, kept consistent across the tree, property editor and child links. --- .../HttpServer/RiaHtmlServer.cpp | 164 ++++++++++-------- ApplicationLibCode/HttpServer/RiaHtmlServer.h | 16 +- 2 files changed, 101 insertions(+), 79 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 01989d3c30..9d1e981389 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -40,10 +40,14 @@ #include "cafPdmPointer.h" #include "cafPdmUiCommandSystemProxy.h" #include "cafPdmUiFieldHandle.h" +#include "cafPdmUiItem.h" #include "cafPdmUiObjectHandle.h" +#include "cafPdmUiTreeOrdering.h" #include "cafPdmValueField.h" #include "cafPdmXmlObjectHandle.h" +#include + #include #include #include @@ -59,6 +63,10 @@ namespace { +// UI config name of the desktop main-window project tree. Object tree ordering (defineUiTreeOrdering) +// and names are config-dependent, e.g. RimEclipseCase only lists its views under this config. +const QString TREE_CONFIG_NAME = "MainWindow.ProjectTree"; + //-------------------------------------------------------------------------------------------------- /// Minimal HTML escaping for text inserted into the generated pages. //-------------------------------------------------------------------------------------------------- @@ -82,6 +90,26 @@ bool isPointerField( caf::PdmValueField* valueField ) return valueField && valueField->toQVariant().userType() == qMetaTypeId>(); } +//-------------------------------------------------------------------------------------------------- +/// Walk a dotted index path (e.g. "0.3.1") from the given root UI-tree node. An empty path returns +/// the root node. Returns nullptr if any index is out of range. The returned node is owned by root. +//-------------------------------------------------------------------------------------------------- +caf::PdmUiTreeOrdering* treeNodeAtPath( caf::PdmUiTreeOrdering* root, const QString& path ) +{ + caf::PdmUiTreeOrdering* node = root; + if ( !node || path.isEmpty() ) return node; + + const QStringList indices = path.split( '.', Qt::SkipEmptyParts ); + for ( const QString& indexText : indices ) + { + bool ok = false; + const int index = indexText.toInt( &ok ); + if ( !ok || !node || index < 0 || index >= node->childCount() ) return nullptr; + node = node->child( index ); + } + return node; +} + //-------------------------------------------------------------------------------------------------- /// Extract the triangle meshes of the active grid view as JSON for the WebGL viewer. /// @@ -389,64 +417,34 @@ caf::PdmObjectHandle* RiaHtmlServer::rootObject() } //-------------------------------------------------------------------------------------------------- -/// Returns the child objects of the given object in field/declaration order. This mirrors the -/// structure of the project data model and gives each child a stable index for addressing. +/// Returns true if the node's subtree represents the target object. //-------------------------------------------------------------------------------------------------- -std::vector RiaHtmlServer::orderedChildren( caf::PdmObjectHandle* object ) +bool RiaHtmlServer::subtreeContainsObject( caf::PdmUiTreeOrdering* node, caf::PdmObjectHandle* target ) { - std::vector children; - if ( !object ) return children; + if ( !node ) return false; + if ( node->isRepresentingObject() && node->object() == target ) return true; - for ( caf::PdmFieldHandle* field : object->fields() ) + for ( int i = 0; i < node->childCount(); ++i ) { - for ( caf::PdmObjectHandle* child : field->children() ) - { - if ( child ) children.push_back( child ); - } - } - - return children; -} - -//-------------------------------------------------------------------------------------------------- -/// Returns true if target is object itself or any descendant of it. -//-------------------------------------------------------------------------------------------------- -bool RiaHtmlServer::subtreeContainsObject( caf::PdmObjectHandle* object, caf::PdmObjectHandle* target ) -{ - if ( !object ) return false; - if ( object == target ) return true; - - for ( caf::PdmObjectHandle* child : orderedChildren( object ) ) - { - if ( subtreeContainsObject( child, target ) ) return true; + if ( subtreeContainsObject( node->child( i ), target ) ) return true; } return false; } //-------------------------------------------------------------------------------------------------- -/// Resolves a dotted path of child indices (e.g. "0.3.1") to an object, starting at the project -/// root. An empty path resolves to the root object. +/// Resolves a dotted path of child indices (e.g. "0.3.1") into the project's UI tree ordering to an +/// object. An empty path resolves to the root object. Returns nullptr for paths that are invalid or +/// that land on a non-object node (a field/title group node). //-------------------------------------------------------------------------------------------------- caf::PdmObjectHandle* RiaHtmlServer::resolvePath( const QString& path ) { - caf::PdmObjectHandle* current = rootObject(); - if ( !current || path.isEmpty() ) return current; - - const QStringList indices = path.split( '.', Qt::SkipEmptyParts ); - for ( const QString& indexText : indices ) - { - bool ok = false; - const int index = indexText.toInt( &ok ); - - std::vector children = orderedChildren( current ); - if ( !ok || index < 0 || index >= static_cast( children.size() ) ) - { - return nullptr; - } - current = children[index]; - } + caf::PdmObjectHandle* root = rootObject(); + if ( !root || !root->uiCapability() ) return nullptr; + if ( path.isEmpty() ) return root; - return current; + std::unique_ptr ordering( root->uiCapability()->uiTreeOrdering( TREE_CONFIG_NAME ) ); + caf::PdmUiTreeOrdering* node = treeNodeAtPath( ordering.get(), path ); + return ( node && node->isRepresentingObject() ) ? node->object() : nullptr; } //-------------------------------------------------------------------------------------------------- @@ -456,15 +454,18 @@ QString RiaHtmlServer::renderTreePage() const { caf::PdmObjectHandle* root = rootObject(); - QString tree; - if ( !root ) + QString tree; + std::unique_ptr ordering; + if ( !root || !root->uiCapability() ) { tree = "

    No project is currently open.

    "; } else { + // Build the tree from the caf UI tree ordering so it mirrors the desktop project tree. + ordering.reset( root->uiCapability()->uiTreeOrdering( TREE_CONFIG_NAME ) ); tree = "
      "; - renderTreeNode( root, "", tree ); + renderTreeNode( ordering.get(), "", tree ); tree += "
    "; } @@ -502,24 +503,30 @@ QString RiaHtmlServer::renderTreePage() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RiaHtmlServer::renderTreeNode( caf::PdmObjectHandle* object, const QString& path, QString& html ) const +void RiaHtmlServer::renderTreeNode( caf::PdmUiTreeOrdering* node, const QString& path, QString& html ) const { - if ( !object ) return; + if ( !node || !node->isValid() ) return; - caf::PdmUiObjectHandle* uiObject = object->uiCapability(); - QString name = uiObject ? uiObject->uiName() : QString(); - if ( name.isEmpty() && object->xmlCapability() ) name = object->xmlCapability()->classKeyword(); + caf::PdmUiItem* item = node->activeItem(); + QString name = item ? item->uiName( TREE_CONFIG_NAME ) : QString(); if ( name.isEmpty() ) name = "Object"; - const QString link = QString( "%2" ).arg( path, htmlEscape( name ) ); - - std::vector children = orderedChildren( object ); + // Object nodes are clickable (load the property editor). Field/title group nodes are plain labels. + QString label; + if ( node->isRepresentingObject() && node->object() ) + { + label = QString( "%2" ).arg( path, htmlEscape( name ) ); + } + else + { + label = htmlEscape( name ); + } html += "
  • "; - if ( children.empty() ) + if ( node->childCount() == 0 ) { // Leaf node: align with parents that show an expander triangle. - html += QString( "%1" ).arg( link ); + html += QString( "%1" ).arg( label ); } else { @@ -527,13 +534,13 @@ void RiaHtmlServer::renderTreeNode( caf::PdmObjectHandle* object, const QString& // node and the chain of nodes leading to the active 3D view are open by default so that view // is revealed; all other nodes start collapsed. caf::PdmObjectHandle* activeView = RiaApplication::instance()->activeReservoirView(); - const bool onActivePath = activeView && subtreeContainsObject( object, activeView ); + const bool onActivePath = activeView && subtreeContainsObject( node, activeView ); const QString openAttr = ( path.isEmpty() || onActivePath ) ? " open" : QString(); - html += "" + link + "
      "; - for ( size_t i = 0; i < children.size(); ++i ) + html += "" + label + "
        "; + for ( int i = 0; i < node->childCount(); ++i ) { const QString childPath = path.isEmpty() ? QString::number( i ) : QString( "%1.%2" ).arg( path ).arg( i ); - renderTreeNode( children[i], childPath, html ); + renderTreeNode( node->child( i ), childPath, html ); } html += "
      "; } @@ -545,7 +552,19 @@ void RiaHtmlServer::renderTreeNode( caf::PdmObjectHandle* object, const QString& //-------------------------------------------------------------------------------------------------- QString RiaHtmlServer::renderObjectPage( const QString& path ) const { - caf::PdmObjectHandle* object = resolvePath( path ); + // Resolve the path against the UI tree ordering so the object and its listed children match the + // tree. The ordering is kept alive for the duration of this method (the child nodes are used + // below); the objects it references outlive it. + caf::PdmObjectHandle* root = rootObject(); + std::unique_ptr ordering; + caf::PdmUiTreeOrdering* node = nullptr; + if ( root && root->uiCapability() ) + { + ordering.reset( root->uiCapability()->uiTreeOrdering( TREE_CONFIG_NAME ) ); + node = treeNodeAtPath( ordering.get(), path ); + } + + caf::PdmObjectHandle* object = ( node && node->isRepresentingObject() ) ? node->object() : nullptr; if ( !object ) { return pageShell( "Object not found", @@ -664,21 +683,22 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const } body += ""; - std::vector children = orderedChildren( object ); - if ( !children.empty() ) + if ( node->childCount() > 0 ) { - body += "

      Children

        "; - for ( size_t i = 0; i < children.size(); ++i ) + QString childList; + for ( int i = 0; i < node->childCount(); ++i ) { - const QString childPath = path.isEmpty() ? QString::number( i ) : QString( "%1.%2" ).arg( path ).arg( i ); + caf::PdmUiTreeOrdering* childNode = node->child( i ); + if ( !childNode || !childNode->isRepresentingObject() || !childNode->object() ) continue; - caf::PdmUiObjectHandle* childUi = children[i]->uiCapability(); - QString childName = childUi ? childUi->uiName() : QString(); + const QString childPath = path.isEmpty() ? QString::number( i ) : QString( "%1.%2" ).arg( path ).arg( i ); + caf::PdmUiItem* childItem = childNode->activeItem(); + QString childName = childItem ? childItem->uiName( TREE_CONFIG_NAME ) : QString(); if ( childName.isEmpty() ) childName = "Object"; - body += QString( "
      • %2
      • " ).arg( childPath, htmlEscape( childName ) ); + childList += QString( "
      • %2
      • " ).arg( childPath, htmlEscape( childName ) ); } - body += "
      "; + if ( !childList.isEmpty() ) body += "

      Children

        " + childList + "
      "; } body += ""; // .objmain diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.h b/ApplicationLibCode/HttpServer/RiaHtmlServer.h index ee450414d5..1caaf2c144 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.h +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.h @@ -30,7 +30,8 @@ class QHttpServerRequest; namespace caf { class PdmObjectHandle; -} +class PdmUiTreeOrdering; +} // namespace caf //================================================================================================== /// @@ -45,7 +46,9 @@ class PdmObjectHandle; /// GET /triangles Triangle meshes of the active grid view as JSON /// GET /viewstate Version counters {view, geometry} for camera and visible-cell changes /// -/// Objects are addressed by a dotted path of child indices from the project root, e.g. "0.3.1". +/// The tree mirrors the desktop project tree: it is built from the caf UI tree ordering +/// (defineUiTreeOrdering) of the project root. Nodes are addressed by a dotted path of child +/// indices into that ordering, e.g. "0.3.1". //================================================================================================== class RiaHtmlServer : public QObject { @@ -66,13 +69,12 @@ class RiaHtmlServer : public QObject static void notifyGeometryChanged(); private: - static caf::PdmObjectHandle* rootObject(); - static std::vector orderedChildren( caf::PdmObjectHandle* object ); - static bool subtreeContainsObject( caf::PdmObjectHandle* object, caf::PdmObjectHandle* target ); - static caf::PdmObjectHandle* resolvePath( const QString& path ); + static caf::PdmObjectHandle* rootObject(); + static bool subtreeContainsObject( caf::PdmUiTreeOrdering* node, caf::PdmObjectHandle* target ); + static caf::PdmObjectHandle* resolvePath( const QString& path ); QString renderTreePage() const; - void renderTreeNode( caf::PdmObjectHandle* object, const QString& path, QString& html ) const; + void renderTreeNode( caf::PdmUiTreeOrdering* node, const QString& path, QString& html ) const; QString renderObjectPage( const QString& path ) const; QString applyFieldChanges( caf::PdmObjectHandle* object, const QHttpServerRequest& request ) const; QString renderTrianglesPage() const; From 9f82f35f50409676ba9ca7d4aeb53ca189c9111d Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Mon, 1 Jun 2026 06:57:05 +0200 Subject: [PATCH 12/12] Show node icons in the HTML project tree Render each tree node's UI icon to a PNG and serve it via a new /icon route. Icons are deduplicated by content so identical icons share one id, keeping the page small and cacheable. Icons are shown in the tree and the children list. --- .../HttpServer/RiaHtmlServer.cpp | 78 ++++++++++++++++++- ApplicationLibCode/HttpServer/RiaHtmlServer.h | 1 + 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp index 9d1e981389..5925d4aa5c 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -52,11 +52,14 @@ #include #include #include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -110,6 +113,57 @@ caf::PdmUiTreeOrdering* treeNodeAtPath( caf::PdmUiTreeOrdering* root, const QStr return node; } +//-------------------------------------------------------------------------------------------------- +/// Process-wide registry of distinct tree icons (as PNG bytes), served via the /icon route. Keyed +/// by the PNG bytes so identical icons (the common case) collapse to a single id, keeping the page +/// small and letting the browser cache each icon once. Accessed only from the server (GUI) thread. +//-------------------------------------------------------------------------------------------------- +std::vector& iconRegistry() +{ + static std::vector registry; + return registry; +} + +//-------------------------------------------------------------------------------------------------- +/// Renders a UI item's icon to a 16x16 PNG, registers it, and returns its id (-1 if there is none). +//-------------------------------------------------------------------------------------------------- +int registerIcon( caf::PdmUiItem* item ) +{ + if ( !item ) return -1; + + std::unique_ptr icon = item->uiIcon( TREE_CONFIG_NAME ); + if ( !icon || icon->isNull() ) return -1; + + const QPixmap pixmap = icon->pixmap( 16, 16 ); + if ( pixmap.isNull() ) return -1; + + QByteArray png; + QBuffer buffer( &png ); + buffer.open( QIODevice::WriteOnly ); + pixmap.toImage().save( &buffer, "PNG" ); + if ( png.isEmpty() ) return -1; + + static QHash indexByPng; + auto it = indexByPng.constFind( png ); + if ( it != indexByPng.constEnd() ) return it.value(); + + std::vector& registry = iconRegistry(); + const int id = static_cast( registry.size() ); + registry.push_back( png ); + indexByPng.insert( png, id ); + return id; +} + +//-------------------------------------------------------------------------------------------------- +/// Returns an tag for a UI item's icon, or an empty string when the item has no icon. +//-------------------------------------------------------------------------------------------------- +QString iconImgTag( caf::PdmUiItem* item ) +{ + const int id = registerIcon( item ); + if ( id < 0 ) return QString(); + return QString( "\"\"" ).arg( id ); +} + //-------------------------------------------------------------------------------------------------- /// Extract the triangle meshes of the active grid view as JSON for the WebGL viewer. /// @@ -365,6 +419,19 @@ bool RiaHtmlServer::start( quint16 preferredPort ) return QHttpServerResponse( QByteArray( "image/png" ), iconFile.readAll() ); } ); + m_httpServer->route( "/icon", + []( const QHttpServerRequest& request ) -> QHttpServerResponse + { + bool ok = false; + const int id = request.query().queryItemValue( "id" ).toInt( &ok ); + const std::vector& registry = iconRegistry(); + if ( !ok || id < 0 || id >= static_cast( registry.size() ) ) + { + return QHttpServerResponse( QHttpServerResponder::StatusCode::NotFound ); + } + return QHttpServerResponse( QByteArray( "image/png" ), registry[id] ); + } ); + m_httpServer->route( "/viewstate", []( const QHttpServerRequest& request ) -> QHttpServerResponse { @@ -512,14 +579,15 @@ void RiaHtmlServer::renderTreeNode( caf::PdmUiTreeOrdering* node, const QString& if ( name.isEmpty() ) name = "Object"; // Object nodes are clickable (load the property editor). Field/title group nodes are plain labels. - QString label; + const QString icon = iconImgTag( item ); + QString label; if ( node->isRepresentingObject() && node->object() ) { - label = QString( "%2" ).arg( path, htmlEscape( name ) ); + label = QString( "%2%3" ).arg( path, icon, htmlEscape( name ) ); } else { - label = htmlEscape( name ); + label = icon + htmlEscape( name ); } html += "
    • "; @@ -696,7 +764,8 @@ QString RiaHtmlServer::renderObjectPage( const QString& path ) const QString childName = childItem ? childItem->uiName( TREE_CONFIG_NAME ) : QString(); if ( childName.isEmpty() ) childName = "Object"; - childList += QString( "
    • %2
    • " ).arg( childPath, htmlEscape( childName ) ); + childList += + QString( "
    • %2%3
    • " ).arg( childPath, iconImgTag( childItem ), htmlEscape( childName ) ); } if ( !childList.isEmpty() ) body += "

      Children

        " + childList + "
      "; } @@ -1011,6 +1080,7 @@ QString RiaHtmlServer::pageShell( const QString& title, const QString& body ) "ul.tree{padding-left:0;}" "details>summary{cursor:pointer;list-style:revert;}" "li .leaf{display:inline-block;padding-left:1.1em;}" + ".treeicon{width:16px;height:16px;vertical-align:-3px;margin-right:4px;}" "a{color:#6fb1ff;text-decoration:none;}" "a:hover{text-decoration:underline;}" ".editorpane-body,body.editor{padding:1.5em;}" diff --git a/ApplicationLibCode/HttpServer/RiaHtmlServer.h b/ApplicationLibCode/HttpServer/RiaHtmlServer.h index 1caaf2c144..13deb62b8c 100644 --- a/ApplicationLibCode/HttpServer/RiaHtmlServer.h +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.h @@ -41,6 +41,7 @@ class PdmUiTreeOrdering; /// GET / Project tree /// GET /object?path=... Property editor for the object at the given tree path /// POST /object?path=... Apply edited field values, then re-render the editor +/// GET /icon?id=... PNG of a tree node icon /// GET /viewsnapshot PNG snapshot of the active 3D view /// GET /trianglesview WebGL page rendering the active view's triangle meshes /// GET /triangles Triangle meshes of the active grid view as JSON
  • FieldKeywordValue
    %1%1%1