diff --git a/ApplicationLibCode/Application/RiaGuiApplication.cpp b/ApplicationLibCode/Application/RiaGuiApplication.cpp index 9b0b813bce..5787811418 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.cpp +++ b/ApplicationLibCode/Application/RiaGuiApplication.cpp @@ -27,6 +27,7 @@ #include "RiaFileLogger.h" #include "RiaFilePathTools.h" #include "RiaFontCache.h" +#include "RiaHtmlServer.h" #include "RiaImportEclipseCaseTools.h" #include "RiaLogging.h" #include "RiaOpenTelemetryManager.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..5925d4aa5c --- /dev/null +++ b/ApplicationLibCode/HttpServer/RiaHtmlServer.cpp @@ -0,0 +1,1108 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiaApplication.h" +#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 "HoloLensCommands/VdeArrayDataPacket.h" +#include "HoloLensCommands/VdeCachingHashedIdFactory.h" +#include "HoloLensCommands/VdePacketDirectory.h" +#include "HoloLensCommands/VdeVizDataExtractor.h" + +#include "cafPdmFieldHandle.h" +#include "cafPdmObjectHandle.h" +#include "cafPdmOptionItemInfo.h" +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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. +//-------------------------------------------------------------------------------------------------- +QString htmlEscape( const QString& text ) +{ + QString escaped = text; + escaped.replace( '&', "&" ); + escaped.replace( '<', "<" ); + escaped.replace( '>', ">" ); + escaped.replace( '"', """ ); + 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>(); +} + +//-------------------------------------------------------------------------------------------------- +/// 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; +} + +//-------------------------------------------------------------------------------------------------- +/// 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. +/// +/// 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 + +std::atomic RiaHtmlServer::sm_viewVersion{ 0 }; +std::atomic RiaHtmlServer::sm_geometryVersion{ 0 }; + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaHtmlServer::RiaHtmlServer( QObject* parent ) + : QObject( parent ) + , m_httpServer( nullptr ) + , m_port( 0 ) +{ +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaHtmlServer::notifyViewChanged() +{ + sm_viewVersion.fetch_add( 1, std::memory_order_relaxed ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaHtmlServer::notifyGeometryChanged() +{ + sm_geometryVersion.fetch_add( 1, std::memory_order_relaxed ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +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() ); + } ); + + 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 ); + } ); + + 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() ); + } ); + + 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( "/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 + { + 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 ) + { + 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 true if the node's subtree represents the target object. +//-------------------------------------------------------------------------------------------------- +bool RiaHtmlServer::subtreeContainsObject( caf::PdmUiTreeOrdering* node, caf::PdmObjectHandle* target ) +{ + if ( !node ) return false; + if ( node->isRepresentingObject() && node->object() == target ) return true; + + for ( int i = 0; i < node->childCount(); ++i ) + { + if ( subtreeContainsObject( node->child( i ), target ) ) return true; + } + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// 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* root = rootObject(); + if ( !root || !root->uiCapability() ) return nullptr; + if ( path.isEmpty() ) return root; + + std::unique_ptr ordering( root->uiCapability()->uiTreeOrdering( TREE_CONFIG_NAME ) ); + caf::PdmUiTreeOrdering* node = treeNodeAtPath( ordering.get(), path ); + return ( node && node->isRepresentingObject() ) ? node->object() : nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::renderTreePage() const +{ + caf::PdmObjectHandle* root = rootObject(); + + 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( ordering.get(), "", tree ); + 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 →

"; + body += tree; + body += "
"; + body += ""; + body += "
"; // .layout + body += "
"; // .appshell + + return pageShell( "ResInsight Project Browser", body ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaHtmlServer::renderTreeNode( caf::PdmUiTreeOrdering* node, const QString& path, QString& html ) const +{ + if ( !node || !node->isValid() ) return; + + caf::PdmUiItem* item = node->activeItem(); + QString name = item ? item->uiName( TREE_CONFIG_NAME ) : QString(); + if ( name.isEmpty() ) name = "Object"; + + // Object nodes are clickable (load the property editor). Field/title group nodes are plain labels. + const QString icon = iconImgTag( item ); + QString label; + if ( node->isRepresentingObject() && node->object() ) + { + label = QString( "%2%3" ).arg( path, icon, htmlEscape( name ) ); + } + else + { + label = icon + htmlEscape( name ); + } + + html += "
  • "; + if ( node->childCount() == 0 ) + { + // Leaf node: align with parents that show an expander triangle. + html += QString( "%1" ).arg( label ); + } + else + { + // 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( node, activeView ); + const QString openAttr = ( path.isEmpty() || onActivePath ) ? " open" : QString(); + 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( node->child( i ), childPath, html ); + } + html += "
    "; + } + html += "
  • "; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaHtmlServer::renderObjectPage( const QString& path ) const +{ + // 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", + "
    " + "

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

    " + "
    " ); + } + + 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 += "
    "; + 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 += ""; + + 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(); + + // Pick an editor based on the field: pointer fields are shown read-only (never editable), a + // filterable drop-down for fields with selectable options (including string fields whose + // options are supplied by the object), 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() ) + { + // 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( ""; + } + 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( editor ); + body += ""; + } + + body += "
    FieldKeywordValue
    %1%1%1
    "; + + if ( valueFieldCount > 0 ) + { + body += "

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

    This object has no editable value fields.

    "; + } + body += "
    "; + + if ( node->childCount() > 0 ) + { + QString childList; + for ( int i = 0; i < node->childCount(); ++i ) + { + caf::PdmUiTreeOrdering* childNode = node->child( i ); + if ( !childNode || !childNode->isRepresentingObject() || !childNode->object() ) continue; + + 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"; + + childList += + QString( "
  • %2%3
  • " ).arg( childPath, iconImgTag( childItem ), htmlEscape( childName ) ); + } + if ( !childList.isEmpty() ) body += "

    Children

      " + childList + "
    "; + } + + body += "
    "; // .objmain + + // 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" ).arg( QDateTime::currentMSecsSinceEpoch() ); + } + if ( RiaApplication::instance()->activeGridView() ) + { + body += "

    3D triangle view

    "; + body += QString( "" ) + .arg( QDateTime::currentMSecsSinceEpoch() ); + } + body += "
    "; // .objside + + body += "
    "; // .objcols + + body += "
    "; // .editorpane-body + + // Filter boxes for long option drop-downs: typing hides the non-matching options in the + // associated