diff --git a/.github/workflows/build-test-linux-vcpkg.yml b/.github/workflows/build-test-linux-vcpkg.yml index 774fe79079e7..da4e51bb88da 100644 --- a/.github/workflows/build-test-linux-vcpkg.yml +++ b/.github/workflows/build-test-linux-vcpkg.yml @@ -115,7 +115,7 @@ jobs: # related issue: https://github.com/actions/checkout/issues/1779 export HOME=${RUNNER_TEMP} git config --global --add safe.directory ${GITHUB_WORKSPACE} - git submodule update --init --recursive --depth 1 thirdparty/imgui thirdparty/mrbind-pybind11 thirdparty/mrbind + git submodule update --init --recursive --depth 1 thirdparty/imgui thirdparty/mrbind-pybind11 thirdparty/mrbind thirdparty/fastmcpp thirdparty/nlohmann-json thirdparty/cpp-httplib - name: Install MRBind if: ${{ inputs.mrbind || inputs.mrbind_c }} diff --git a/.github/workflows/build-test-ubuntu-arm64.yml b/.github/workflows/build-test-ubuntu-arm64.yml index 1c908382c4bb..f470eb9a7e9c 100644 --- a/.github/workflows/build-test-ubuntu-arm64.yml +++ b/.github/workflows/build-test-ubuntu-arm64.yml @@ -99,6 +99,7 @@ jobs: run: | ln -s /usr/local/lib/meshlib-thirdparty-lib/lib ./lib ln -s /usr/local/lib/meshlib-thirdparty-lib/include ./include + ln -s /usr/local/lib/meshlib-thirdparty-lib/share ./share - name: Install MRBind if: ${{ inputs.mrbind || inputs.mrbind_c }} diff --git a/.github/workflows/build-test-ubuntu-x64.yml b/.github/workflows/build-test-ubuntu-x64.yml index 957c9d2dcfa6..ed20dc196c21 100644 --- a/.github/workflows/build-test-ubuntu-x64.yml +++ b/.github/workflows/build-test-ubuntu-x64.yml @@ -78,6 +78,7 @@ jobs: run: | ln -s /usr/local/lib/meshlib-thirdparty-lib/lib ./lib ln -s /usr/local/lib/meshlib-thirdparty-lib/include ./include + ln -s /usr/local/lib/meshlib-thirdparty-lib/share ./share - name: Install MRBind if: ${{ inputs.mrbind || inputs.mrbind_c }} diff --git a/.github/workflows/update-docs-manual.yml b/.github/workflows/update-docs-manual.yml index 1862b36ee64e..a8b9be47c0de 100644 --- a/.github/workflows/update-docs-manual.yml +++ b/.github/workflows/update-docs-manual.yml @@ -47,11 +47,12 @@ jobs: export HOME=${RUNNER_TEMP} git config --global --add safe.directory '*' git submodule update --init --recursive --depth 1 thirdparty/imgui thirdparty/eigen thirdparty/parallel-hashmap thirdparty/mrbind-pybind11 thirdparty/mrbind - + - name: Install thirdparty libs run: | ln -s /usr/local/lib/meshlib-thirdparty-lib/lib ./lib ln -s /usr/local/lib/meshlib-thirdparty-lib/include ./include + ln -s /usr/local/lib/meshlib-thirdparty-lib/share ./share - name: Install mrbind run: scripts/mrbind/install_mrbind_ubuntu.sh diff --git a/.gitmodules b/.gitmodules index 5e84140c007d..ef9d7a0d5172 100644 --- a/.gitmodules +++ b/.gitmodules @@ -76,3 +76,12 @@ [submodule "thirdparty/OpenCTM-git"] path = thirdparty/OpenCTM-git url = https://github.com/MeshInspector/OpenCTM.git +[submodule "thirdparty/fastmcpp"] + path = thirdparty/fastmcpp + url = https://github.com/MeshInspector/fastmcpp +[submodule "thirdparty/nlohmann-json"] + path = thirdparty/nlohmann-json + url = https://github.com/nlohmann/json +[submodule "thirdparty/cpp-httplib"] + path = thirdparty/cpp-httplib + url = https://github.com/yhirose/cpp-httplib diff --git a/.gitpod.yml b/.gitpod.yml index 766c01e8be62..dc01eb0def10 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -9,3 +9,4 @@ tasks: init: | ln -s /usr/local/lib/meshlib-thirdparty-lib/lib ./lib ln -s /usr/local/lib/meshlib-thirdparty-lib/include ./include + ln -s /usr/local/lib/meshlib-thirdparty-lib/share ./share diff --git a/CMakeLists.txt b/CMakeLists.txt index 98a727dea245..69e33798056c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,10 +53,14 @@ option(MESHLIB_BUILD_MESHCONV "Build meshconv utility" ON) option(MESHLIB_BUILD_SYMBOLMESH "Build symbol-to-mesh library" ON) option(MESHLIB_BUILD_VOXELS "Build voxels library" ON) option(MESHLIB_BUILD_EXTRA_IO_FORMATS "Build extra IO format support library" ON) +option(MESHLIB_BUILD_MCP "Enable MCP server" ON) option(MESHLIB_BUILD_GENERATED_C_BINDINGS "Build C bindings (assuming they are already generated)" OFF) option(MESHLIB_BUILD_MRCUDA "Build MRCuda library" ON) option(MESHLIB_EXPERIMENTAL_HIP "(experimental) Use HIP toolkit for MRCuda library" OFF) +IF(MR_EMSCRIPTEN) + set(MESHLIB_BUILD_MCP OFF) +ENDIF() IF(MR_EMSCRIPTEN OR APPLE) set(MESHLIB_BUILD_MRCUDA OFF) ENDIF() @@ -293,6 +297,10 @@ IF(NOT MR_EMSCRIPTEN AND NOT APPLE) ENDIF() ENDIF() +IF(MESHLIB_BUILD_MCP AND NOT MR_EMSCRIPTEN) + add_subdirectory(${PROJECT_SOURCE_DIR}/MRMcp ./MRMcp) +ENDIF() + IF(BUILD_TESTING) enable_testing() add_subdirectory(${PROJECT_SOURCE_DIR}/MRTest ./MRTest) diff --git a/cmake/Modules/CompilerOptions.cmake b/cmake/Modules/CompilerOptions.cmake index 61e077fe452e..1df0ee1168aa 100644 --- a/cmake/Modules/CompilerOptions.cmake +++ b/cmake/Modules/CompilerOptions.cmake @@ -32,7 +32,7 @@ ENDIF() # Warnings and misc compiler settings. IF(MSVC) # C++-specific flags. - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /DImDrawIdx=unsigned /D_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING /D_SILENCE_CXX20_OLD_SHARED_PTR_ATOMIC_SUPPORT_DEPRECATION_WARNING /D_SILENCE_CXX23_ALIGNED_STORAGE_DEPRECATION_WARNING /D_SILENCE_CXX23_DENORM_DEPRECATION_WARNING /D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /DImDrawIdx=unsigned /D_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING /D_SILENCE_CXX20_OLD_SHARED_PTR_ATOMIC_SUPPORT_DEPRECATION_WARNING /D_SILENCE_CXX23_ALIGNED_STORAGE_DEPRECATION_WARNING /D_SILENCE_CXX23_DENORM_DEPRECATION_WARNING /D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR") # Common C/C++ flags: diff --git a/docker/ubuntu22Dockerfile b/docker/ubuntu22Dockerfile index 2c9e7aa381e6..058a0ad4be60 100644 --- a/docker/ubuntu22Dockerfile +++ b/docker/ubuntu22Dockerfile @@ -44,6 +44,7 @@ COPY scripts/mrbind-pybind11/install_all_python_versions_ubuntu_pkgs.sh scripts/ COPY --from=build /home/MeshLib/lib /usr/local/lib/meshlib-thirdparty-lib/lib COPY --from=build /home/MeshLib/include /usr/local/lib/meshlib-thirdparty-lib/include +COPY --from=build /home/MeshLib/share /usr/local/lib/meshlib-thirdparty-lib/share ENV MR_STATE=DOCKER_BUILD diff --git a/docker/ubuntu24Dockerfile b/docker/ubuntu24Dockerfile index 892f0b599357..8e87c2630c65 100644 --- a/docker/ubuntu24Dockerfile +++ b/docker/ubuntu24Dockerfile @@ -52,6 +52,7 @@ COPY scripts/mrbind-pybind11/install_all_python_versions_ubuntu_pkgs.sh scripts/ COPY --from=build /home/MeshLib/lib /usr/local/lib/meshlib-thirdparty-lib/lib COPY --from=build /home/MeshLib/include /usr/local/lib/meshlib-thirdparty-lib/include +COPY --from=build /home/MeshLib/share /usr/local/lib/meshlib-thirdparty-lib/share COPY --from=cuda /usr/local/cuda-12.6 /usr/local/cuda-12.6 ENV MR_STATE=DOCKER_BUILD diff --git a/docs/testing_mcp.md b/docs/testing_mcp.md new file mode 100644 index 000000000000..fc37f01969d8 --- /dev/null +++ b/docs/testing_mcp.md @@ -0,0 +1,25 @@ +# How to test MCP? + +Use "MCP Inspector". Run it with `npx @modelcontextprotocol/inspector`, where `npx` is installed as a part of Node.JS. + +Set: +* Transport Type = SSE +* URL = http://localhost:8080/sse +* Connection Type = Via Proxy (Doesn't work for me without proxy now when we're using the Fastmcpp library, but did work with another library; not sure why.) + +Press `Connect`. + +Press `List Tools` (if grayed out, do `Clear` first). + +Click on your tool. + +On the right panel, set parameters. For some parameter types, it helps to press `Switch to JSON` on the right, then type them as JSON. + +Press `Run Tool`. If you get weird errors, try pressing it again. In some cases, the first press passes stale/empty parameters. + +Then check for validation errors, below this button. + +If it complains that your output doesn't match the schema you specified, paste both the output and the schema (using the `Copy` button in the top-right corner of the code blocks; that copies JSON properly, unlike Ctrl+C in this case) + into a schema validator, e.g. https://www.jsonschemavalidator.net/ + +**NOTE:** It doesn't seem to validate the input schema (only output schema). Check it by eye. diff --git a/scripts/ask_emscripten_mode.src b/scripts/ask_emscripten_mode.src new file mode 100644 index 000000000000..5179cf27d764 --- /dev/null +++ b/scripts/ask_emscripten_mode.src @@ -0,0 +1,40 @@ +#!/bin/false +# This file is supposed to be sourced. + +[[ ${MR_EMSCRIPTEN_SINGLETHREAD:=} ]] || export MR_EMSCRIPTEN_SINGLETHREAD=0 +[[ ${MR_EMSCRIPTEN_WASM64:=} ]] || export MR_EMSCRIPTEN_WASM64=0 + +if [[ $OSTYPE == "linux"* && $MR_STATE != "DOCKER_BUILD" ]]; then + if [ ! -n "$MR_EMSCRIPTEN" ]; then + read -t 5 -p "Build with emscripten? Press (y) in 5 seconds to build (y/s/l/N) (s - singlethreaded, l - 64-bit)" -rsn 1 + echo; + case $REPLY in + Y|y) + export MR_EMSCRIPTEN="ON";; + S|s) + export MR_EMSCRIPTEN="ON" + export MR_EMSCRIPTEN_SINGLETHREAD=1;; + L|l) + export MR_EMSCRIPTEN="ON" + export MR_EMSCRIPTEN_WASM64=1;; + *) + export MR_EMSCRIPTEN="OFF";; + esac + fi +else + if [ ! -n "$MR_EMSCRIPTEN" ]; then + MR_EMSCRIPTEN="OFF" + fi +fi + +# Normalize the spelling of some variables. +if [ $MR_EMSCRIPTEN == "ON" ]; then + if [[ $MR_EMSCRIPTEN_SINGLE == "ON" ]]; then + MR_EMSCRIPTEN_SINGLETHREAD=1 + fi + if [[ $MR_EMSCRIPTEN_WASM64 == "ON" ]]; then + MR_EMSCRIPTEN_WASM64=1 + fi +fi + +echo "Emscripten ${MR_EMSCRIPTEN}, singlethread ${MR_EMSCRIPTEN_SINGLETHREAD}, 64-bit ${MR_EMSCRIPTEN_WASM64}" diff --git a/scripts/build_source.sh b/scripts/build_source.sh index f340e22e4003..bb1bd4a4d783 100755 --- a/scripts/build_source.sh +++ b/scripts/build_source.sh @@ -8,39 +8,9 @@ logfile="`pwd`/build_source_${dt}.log" echo "Project build script started." echo "You could find output in ${logfile}" -MR_EMSCRIPTEN_SINGLETHREAD=0 -if [[ $OSTYPE == "linux"* ]]; then - if [ ! -n "$MR_EMSCRIPTEN" ]; then - read -t 5 -p "Build with emscripten? Press (y) in 5 seconds to build (y/s/l/N) (s - singlethreaded, l - 64-bit)" -rsn 1 - echo; - case $REPLY in - Y|y) - MR_EMSCRIPTEN="ON";; - S|s) - MR_EMSCRIPTEN="ON" - MR_EMSCRIPTEN_SINGLETHREAD=1;; - L|l) - MR_EMSCRIPTEN="ON" - MR_EMSCRIPTEN_WASM64=1;; - *) - MR_EMSCRIPTEN="OFF";; - esac - fi -else - if [ ! -n "$MR_EMSCRIPTEN" ]; then - MR_EMSCRIPTEN="OFF" - fi -fi -echo "Emscripten ${MR_EMSCRIPTEN}, singlethread ${MR_EMSCRIPTEN_SINGLETHREAD}, 64-bit ${MR_EMSCRIPTEN_WASM64}" +SCRIPT_DIR="$(dirname "$BASH_SOURCE")" -if [ $MR_EMSCRIPTEN == "ON" ]; then - if [[ $MR_EMSCRIPTEN_SINGLE == "ON" ]]; then - MR_EMSCRIPTEN_SINGLETHREAD=1 - fi - if [[ $MR_EMSCRIPTEN_WASM64 == "ON" ]]; then - MR_EMSCRIPTEN_WASM64=1 - fi -fi +. "$SCRIPT_DIR/ask_emscripten_mode.src" if [ ! -n "$MESHLIB_BUILD_RELEASE" ]; then read -t 5 -p "Build MeshLib Release? Press (n) in 5 seconds to cancel (Y/n)" -rsn 1 diff --git a/scripts/build_thirdparty.sh b/scripts/build_thirdparty.sh index a21bda8993e2..fa1a3c760a00 100755 --- a/scripts/build_thirdparty.sh +++ b/scripts/build_thirdparty.sh @@ -32,38 +32,10 @@ else echo "Host system: ${OSTYPE}" fi -MR_EMSCRIPTEN_SINGLETHREAD=0 -if [[ $OSTYPE == "linux"* ]] && [ "${MR_STATE}" != "DOCKER_BUILD" ]; then - if [ ! -n "$MR_EMSCRIPTEN" ]; then - read -t 5 -p "Build with emscripten? Press (y) in 5 seconds to build (y/s/l/N) (s - singlethreaded, l - 64-bit)" -rsn 1 - echo; - case $REPLY in - Y|y) - MR_EMSCRIPTEN="ON";; - S|s) - MR_EMSCRIPTEN="ON" - MR_EMSCRIPTEN_SINGLETHREAD=1;; - L|l) - MR_EMSCRIPTEN="ON" - MR_EMSCRIPTEN_WASM64=1;; - *) - MR_EMSCRIPTEN="OFF";; - esac - fi -else - if [ ! -n "$MR_EMSCRIPTEN" ]; then - MR_EMSCRIPTEN="OFF" - fi -fi -echo "Emscripten ${MR_EMSCRIPTEN}, singlethread ${MR_EMSCRIPTEN_SINGLETHREAD}, 64-bit ${MR_EMSCRIPTEN_WASM64}" +. "$SCRIPT_DIR/ask_emscripten_mode.src" if [ $MR_EMSCRIPTEN == "ON" ]; then - if [[ $MR_EMSCRIPTEN_SINGLE == "ON" ]]; then - MR_EMSCRIPTEN_SINGLETHREAD=1 - fi - if [[ $MR_EMSCRIPTEN_WASM64 == "ON" ]]; then - MR_EMSCRIPTEN_WASM64=1 - fi + true # Nothing. elif [ -n "${INSTALL_REQUIREMENTS}" ]; then echo "Check requirements. Running ${INSTALL_REQUIREMENTS} ..." ${SCRIPT_DIR}/$INSTALL_REQUIREMENTS @@ -165,6 +137,17 @@ else # build clip separately CMAKE_OPTIONS="${MR_CMAKE_OPTIONS}" ${SCRIPT_DIR}/thirdparty/clip.sh ${MESHLIB_THIRDPARTY_DIR}/clip + + # Skip this on Mac, we use `add_subdirectory()` for those libraries there. + # This is because we can't use `find_package()` there to find our own libraries, because that breaks Python modules, as documented in the root `CMakeLists.txt`. + if [[ $OSTYPE != 'darwin'* ]]; then + # Build nlohmann-json separately. It is header-only, this just installs it. It is a dependency of fastmcpp. + CMAKE_OPTIONS="${MR_CMAKE_OPTIONS}" ${SCRIPT_DIR}/thirdparty/nlohmann-json.sh "$MESHLIB_THIRDPARTY_DIR/nlohmann-json" + # Build cpp-httplib separately. It is header-only, this just installs it. It is a dependency of fastmcpp. + CMAKE_OPTIONS="${MR_CMAKE_OPTIONS}" ${SCRIPT_DIR}/thirdparty/cpp-httplib.sh "$MESHLIB_THIRDPARTY_DIR/cpp-httplib" + # Build fastmcpp separately. + CMAKE_OPTIONS="${MR_CMAKE_OPTIONS}" ${SCRIPT_DIR}/thirdparty/fastmcpp.sh "$MESHLIB_THIRDPARTY_DIR/fastmcpp" ./fastmcpp_build "${MESHLIB_THIRDPARTY_ROOT_DIR}" + fi fi popd diff --git a/scripts/thirdparty/cpp-httplib.sh b/scripts/thirdparty/cpp-httplib.sh new file mode 100755 index 000000000000..918b8a36d005 --- /dev/null +++ b/scripts/thirdparty/cpp-httplib.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -eo pipefail + +SOURCE_DIR="$1" +BUILD_DIR="${2:-./cpp-httplib_build}" + +CMAKE_OPTIONS="${CMAKE_OPTIONS} \ + -D HTTPLIB_TEST=OFF \ + -D HTTPLIB_USE_BROTLI_IF_AVAILABLE=OFF \ + -D HTTPLIB_USE_OPENSSL_IF_AVAILABLE=OFF \ + -D HTTPLIB_USE_ZLIB_IF_AVAILABLE=OFF \ + -D HTTPLIB_USE_ZSTD_IF_AVAILABLE=OFF \ +" + +cmake -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -D CMAKE_C_FLAGS="${CFLAGS}" ${CMAKE_OPTIONS} +cmake --build "${BUILD_DIR}" -j `nproc` +cmake --install "${BUILD_DIR}" diff --git a/scripts/thirdparty/fastmcpp.sh b/scripts/thirdparty/fastmcpp.sh new file mode 100755 index 000000000000..7b9e241a1c11 --- /dev/null +++ b/scripts/thirdparty/fastmcpp.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -exo pipefail + +SOURCE_DIR="$1" +BUILD_DIR="${2:-./fastmcpp_build}" + +# Sync those flags with `source/fastmcpp/CMakeLists.txt`. +CMAKE_OPTIONS="${CMAKE_OPTIONS} \ + -D FASTMCPP_BUILD_TESTS=OFF \ + -D FASTMCPP_BUILD_EXAMPLES=OFF \ + -D FASTMCPP_FETCH_CURL=OFF \ + -D CMAKE_CXX_FLAGS=-fPIC \ +" + +cmake -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -D CMAKE_C_FLAGS="${CFLAGS}" ${CMAKE_OPTIONS} +cmake --build "${BUILD_DIR}" -j `nproc` +cmake --install "${BUILD_DIR}" diff --git a/scripts/thirdparty/nlohmann-json.sh b/scripts/thirdparty/nlohmann-json.sh new file mode 100755 index 000000000000..6141c0ef51c5 --- /dev/null +++ b/scripts/thirdparty/nlohmann-json.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -eo pipefail + +SOURCE_DIR="$1" +BUILD_DIR="${2:-./nlohmann-json_build}" + +CMAKE_OPTIONS="${CMAKE_OPTIONS} \ + -D JSON_BuildTests=OFF \ +" + +cmake -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -D CMAKE_C_FLAGS="${CFLAGS}" ${CMAKE_OPTIONS} +cmake --build "${BUILD_DIR}" -j `nproc` +cmake --install "${BUILD_DIR}" diff --git a/source/MRMcp/CMakeLists.txt b/source/MRMcp/CMakeLists.txt new file mode 100644 index 000000000000..e88278e7e200 --- /dev/null +++ b/source/MRMcp/CMakeLists.txt @@ -0,0 +1,55 @@ +project(MRMcp CXX) + +file(GLOB HEADERS "*.h" "*.ipp") +file(GLOB SOURCES "*.cpp") + +add_library(${PROJECT_NAME} SHARED ${SOURCES} ${HEADERS}) + +target_link_libraries(${PROJECT_NAME} + PUBLIC + MRMesh +) + +IF(MR_PCH) + target_precompile_headers(${PROJECT_NAME} REUSE_FROM MRPch) +ENDIF() + +# On Windows, and on Linux+Vcpkg there is no third-party build script, so add fastmcpp as a subdirectory. +IF(MESHLIB_USE_VCPKG OR APPLE) + set(FASTMCPP_DEPS_ADD_SUBDIRECTORY ON) + add_subdirectory(../fastmcpp fastmcpp) + target_link_libraries(${PROJECT_NAME} PRIVATE fastmcpp_core) + target_include_directories(${PROJECT_NAME} PRIVATE + ${MESHLIB_THIRDPARTY_DIR}/fastmcpp/include + ${MESHLIB_THIRDPARTY_DIR}/cpp-httplib + ${MESHLIB_THIRDPARTY_DIR}/nlohmann-json/include + ) +ELSE() + find_package(fastmcpp REQUIRED) + target_link_libraries(${PROJECT_NAME} PUBLIC fastmcpp::fastmcpp_core) +ENDIF() + +install( + TARGETS ${PROJECT_NAME} + EXPORT ${PROJECT_NAME} + LIBRARY DESTINATION "${MR_MAIN_LIB_DIR}" + ARCHIVE DESTINATION "${MR_MAIN_LIB_DIR}" + RUNTIME DESTINATION "${MR_BIN_DIR}" +) + +install( + FILES ${HEADERS} + DESTINATION "${MR_INCLUDE_DIR}/${PROJECT_NAME}" +) + +install( + FILES ${CMAKE_CURRENT_SOURCE_DIR}/${PROJECT_NAME}Config.cmake + DESTINATION ${MR_CONFIG_DIR} +) + +install( + EXPORT ${PROJECT_NAME} + FILE ${PROJECT_NAME}Targets.cmake + NAMESPACE MeshLib:: + DESTINATION ${MR_CONFIG_DIR} +) diff --git a/source/MRMcp/MRMcp.cpp b/source/MRMcp/MRMcp.cpp new file mode 100644 index 000000000000..fdc76f232446 --- /dev/null +++ b/source/MRMcp/MRMcp.cpp @@ -0,0 +1,146 @@ +// Must not include any standard headers + +#undef _t // Our translation macro interefers with Fastmcpp. + +#if defined( __GNUC__ ) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#elif defined( _MSC_VER ) +#pragma warning( push ) +#pragma warning( disable: 4100 ) // unreferenced formal parameter +#pragma warning( disable: 4355 ) // 'this': used in base member initializer list +#endif + +// This must be included before any standard library headers, because of the macro shenanigans we added to that header. +// Those are duplicated into our PCH, so that shouldn't interfere. +#include +#include + +#if defined( __GNUC__ ) +#pragma GCC diagnostic pop +#elif defined( _MSC_VER ) +#pragma warning( pop ) +#endif + +#include "MRMcp.h" + +#include "MRMesh/MRSystem.h" +#include "MRPch/MRSpdlog.h" +#include "MRViewer/MRCommandLoop.h" +#include "MRViewer/MRUITestEngineControl.h" +#include "MRViewer/MRViewer.h" + +#include + + +namespace MR::Mcp +{ + +struct Server::State +{ + fastmcpp::tools::ToolManager toolManager; // This has to be persistent, or `fastmcpp::mcp::make_mcp_handler()` dangles it. + std::unordered_map toolDescs; // No idea why this is not a part of `toolManager`. + std::optional server; + + void createServer( const Params& params ) + { + assert( !server ); + server.emplace( fastmcpp::mcp::make_mcp_handler( params.name, params.version, toolManager, toolDescs ), params.address, params.port ); + } +}; + +Server::Params::Params() + : name( getProductName() ), + version( GetMRVersionString() ) +{} + +Server::Server() = default; +Server::Server( Server&& ) = default; +Server& Server::operator=( Server&& ) = default; +Server::~Server() = default; + +bool Server::addTool( std::string id, std::string name, std::string desc, Schema::Base inputSchema, Schema::Base outputSchema, ToolFunc func ) +{ + if ( !state_ ) + { + state_ = std::make_unique(); + } + else if ( state_->server ) + { + assert( false && "`MR::Mcp::Server::addTool()`: Called too late, the server is already initialized." ); + return false; + } + + // Why is managing the descriptions not automated by the library? + if ( !state_->toolDescs.try_emplace( id, desc ).second ) + { + assert( false && "`MR::Mcp::Server::addTool()`: Duplicate tool id." ); + return false; + } + + state_->toolManager.register_tool( fastmcpp::tools::Tool( id, std::move( inputSchema ).asJson(), std::move( outputSchema ).asJson(), func, name, desc, {} ) ); + return true; +} + +const Server::Params& Server::getParams() const +{ + return params_; +} + +void Server::setParams( Server::Params params ) +{ + const bool serverExisted = state_ && bool( state_->server ); + const bool serverWasRunning = serverExisted && isRunning(); + + if ( serverWasRunning ) + setRunning( false ); + if ( serverExisted ) + state_->server.reset(); + + params_ = std::move( params ); + + if ( serverExisted ) + state_->createServer( params ); + if ( serverWasRunning ) + setRunning( true ); +} + +bool Server::isRunning() const +{ + return state_ && state_->server && state_->server->running(); +} + +bool Server::setRunning( bool enable ) +{ + if ( enable ) + { + if ( !state_ ) + state_ = std::make_unique(); + if ( !state_->server ) + state_->createServer( params_ ); + + bool ok = state_->server->start(); + if ( ok ) + spdlog::info( "MCP server started on port {}", getParams().port ); + else + spdlog::error( "MCP server failed to start on port {}", getParams().port ); + return ok; + } + else + { + if ( state_ && state_->server ) + { + state_->server->stop(); + spdlog::info( "MCP server stopped" ); + } + return true; + } +} + +Server& getDefaultServer() +{ + static Server ret; + return ret; +} + +} // namespace MR diff --git a/source/MRMcp/MRMcp.h b/source/MRMcp/MRMcp.h new file mode 100644 index 000000000000..acb80d04395d --- /dev/null +++ b/source/MRMcp/MRMcp.h @@ -0,0 +1,164 @@ +#pragma once + +#include "exports.h" + +#include + +#include +#include + +namespace MR::Mcp +{ + +/// This is used to build json schemas. +namespace Schema +{ + /// A common base class for the different schemas. Functions can accept this by value, it's fine to slice it. + struct Base + { + protected: + nlohmann::json json; + Base( nlohmann::json json ) : json( std::move( json ) ) {} + + public: + [[nodiscard]] const nlohmann::json& asJson() const & { return json; } + [[nodiscard]] nlohmann::json&& asJson() && { return std::move( json ); } + }; + + /// An empty schema. + struct Empty : Base + { + Empty() : Base( {} ) {} + }; + + /// A schema describing a scalar. + struct Number : Base + { + Number() + : Base( nlohmann::json::object( { + { "type", "number" }, + } ) ) + {} + }; + + /// A schema describing a string. + struct String : Base + { + String() + : Base( nlohmann::json::object( { + { "type", "string" }, + } ) ) + {} + }; + + /// A schema describing an array of whatever is passed to the constructor. + struct Array : Base + { + Array( Base elemSchema ) + : Base( nlohmann::json::object( { + { "type", "array" }, + { "items", std::move( elemSchema ).asJson() }, + } ) ) + {} + }; + + /// A schema describing an object. + /// Construct like this: `Object{}.addMember(...).addMember(...)`. + struct Object : Base + { + Object() + : Base( nlohmann::json::object( { + { "type", "object" }, + { "properties", nlohmann::json::object() }, + { "required", nlohmann::json::array() }, + } ) ) + {} + + /// Add required member. Returns a reference to `*this`. + Object &addMember( std::string name, Base schema ) & + { + json.at("required").push_back( name ); + addMemberOpt( std::move( name ), std::move( schema ) ); + return *this; + } + /// Add optional member. Returns a reference to `*this`. + Object &addMemberOpt( std::string name, Base schema ) & + { + json.at( "properties" ).push_back( nlohmann::json::object_t::value_type( std::move( name ), std::move( schema ).asJson() ) ); + return *this; + } + + /// Add required member. Returns a reference to `*this`. + [[nodiscard]] Object&& addMember( std::string name, Base schema ) && + { + addMember( std::move( name ), std::move( schema ) ); + return std::move( *this ); + } + /// Add optional member. Returns a reference to `*this`. + [[nodiscard]] Object&& addMemberOpt( std::string name, Base schema ) && + { + addMemberOpt( std::move( name ), std::move( schema ) ); + return std::move( *this ); + } + }; +} // namespace Schema + +/// Owns a HTTP MCP server (using the SSE protocol). +class Server +{ +public: + struct Params + { + std::string address = "127.0.0.1"; ///< You don't need to change this, unless you want to accept connections from the outside world. + int port = 7887; + std::string name; ///< A default string is set in the constructor. + std::string version; ///< A default string is set in the constructor. + + friend bool operator==( const Params&, const Params& ) = default; + + MRMCP_API Params(); + }; + + MRMCP_API Server(); + MRMCP_API Server( Server&& ); + MRMCP_API Server& operator=( Server&& ); + MRMCP_API ~Server(); + + /// Those functions are allowed to throw, that's how you report errors to the MCP. + using ToolFunc = std::function; + + /// Registers a new tool. + /// @param id An arbitrary function name, e.g. `foo.bar`. + /// @param name A human/ai-readable name. + /// @param desc A human/ai-readable explanation of what the tool does. + /// @param inputSchema Describes the arguments. Normally it should be `Schema::Object{}` with some fields added. + /// @param outputSchema Describes the returned JSON. + /// Fails if the tool with this `id` already exists. + /// Must be called early, before `setRunning(true)` is called for the first time, otherwise fails. + /// Returns true on success. Asserts when returning false, so you don't have to check the return value. + /// NOTE: Consult `docs/testing_mcp.md` for how to test your tool. + MRMCP_API bool addTool( std::string id, std::string name, std::string desc, Schema::Base inputSchema, Schema::Base outputSchema, ToolFunc func ); + + [[nodiscard]] MRMCP_API const Params& getParams() const; + + /// This restarts the server if necessary. + MRMCP_API void setParams( Params params ); + + [[nodiscard]] MRMCP_API bool isRunning() const; + /// Returns true on success, including if the server is already running and you're trying to start it again. + /// Stopping always returns true. + MRMCP_API bool setRunning( bool enable ); + +private: + struct State; + + /// This is null until either `setParams()` or `setRunning(true)` is called for the first time. + std::unique_ptr state_; + + Params params_; +}; + +/// The global instance of the MCP server. +[[nodiscard]] MRMCP_API Server& getDefaultServer(); + +} // namespace MR diff --git a/source/MRMcp/MRMcp.vcxproj b/source/MRMcp/MRMcp.vcxproj new file mode 100644 index 000000000000..f612bac12ec6 --- /dev/null +++ b/source/MRMcp/MRMcp.vcxproj @@ -0,0 +1,107 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + + + + + + + + {7853aec9-a364-4587-89ae-faa9a463e6ed} + + + {c7780500-ca0e-4f5f-8423-d7ab06078b14} + + + + 15.0 + {C8250F26-E01D-4A63-98CD-68069D818080} + Win32Proj + MRMcp + + + + DynamicLibrary + true + Unicode + + + DynamicLibrary + false + false + Unicode + + + + + + + + + + + + + + + + + + NotUsing + EnableAllWarnings + Disabled + true + MRMcp_EXPORTS;_DEBUG;%(PreprocessorDefinitions) + true + true + %(AdditionalIncludeDirectories);..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include + 4099;4100;4242;4244;4355;4456;4458;4464;4505;4702;5204;5220;5233;5245;5267;%(DisableSpecificWarnings) + + + Console + true + %(AdditionalDependencies) + + + + + + + + + NotUsing + EnableAllWarnings + MaxSpeed + true + true + true + MRMcp_EXPORTS;NDEBUG;%(PreprocessorDefinitions) + true + true + /bigobj %(AdditionalOptions) + %(AdditionalIncludeDirectories);..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include + 4099;4100;4242;4244;4355;4456;4458;4464;4505;4702;5204;5220;5233;5245;5267;%(DisableSpecificWarnings) + + + Console + true + true + true + + + + + + diff --git a/source/MRMcp/MRMcpConfig.cmake b/source/MRMcp/MRMcpConfig.cmake new file mode 100644 index 000000000000..2ef41d17fded --- /dev/null +++ b/source/MRMcp/MRMcpConfig.cmake @@ -0,0 +1,5 @@ +include(CMakeFindDependencyMacro) + +IF(NOT MESHLIB_USE_VCPKG AND NOT APPLE) + find_dependency(fastmcpp) +endif() diff --git a/source/MRMcp/exports.h b/source/MRMcp/exports.h new file mode 100644 index 000000000000..cd31c682c5a6 --- /dev/null +++ b/source/MRMcp/exports.h @@ -0,0 +1,18 @@ +#pragma once + +// see explanation in MRMesh/MRMeshFwd.h +#ifdef _WIN32 +# ifdef MRMcp_EXPORTS +# define MRMCP_API __declspec(dllexport) +# else +# define MRMCP_API __declspec(dllimport) +# endif +# define MRMCP_CLASS +#else +# define MRMCP_API __attribute__((visibility("default"))) +# ifdef __clang__ +# define MRMCP_CLASS __attribute__((type_visibility("default"))) +# else +# define MRMCP_CLASS __attribute__((visibility("default"))) +# endif +#endif diff --git a/source/MRMesh/MRSystem.cpp b/source/MRMesh/MRSystem.cpp index d7189e597fd2..6ed2d726179f 100644 --- a/source/MRMesh/MRSystem.cpp +++ b/source/MRMesh/MRSystem.cpp @@ -94,6 +94,11 @@ void removeOldLogs( const std::filesystem::path& dir, int hours = 24 ) namespace MR { +std::string getProductName() +{ + return MR_PROJECT_NAME; +} + void SetCurrentThreadName( const char * name ) { #ifdef _MSC_VER diff --git a/source/MRMesh/MRSystem.h b/source/MRMesh/MRSystem.h index 1ecb840327e1..a0eb80ce700c 100644 --- a/source/MRMesh/MRSystem.h +++ b/source/MRMesh/MRSystem.h @@ -8,6 +8,9 @@ namespace MR { +// The name of the current application. +[[nodiscard]] MRMESH_API MR_BIND_IGNORE std::string getProductName(); + // sets debug name for the current thread MRMESH_API void SetCurrentThreadName( const char * name ); diff --git a/source/MRPch/MRPch.h b/source/MRPch/MRPch.h index 794d98476302..da3d3b1459dc 100644 --- a/source/MRPch/MRPch.h +++ b/source/MRPch/MRPch.h @@ -1,5 +1,14 @@ #pragma once +// Work around Clang quirk: https://github.com/llvm/llvm-project/issues/86077 +// This quirk causes issues for Fastmcpp on Mac Arm. +// This must be included before `` to work correctly, so effectively before any standard library headers. +#if defined( __APPLE__ ) && defined( __arm64__ ) +#include +#undef _LIBCPP_AVAILABILITY_HAS_INIT_PRIMARY_EXCEPTION +#define _LIBCPP_AVAILABILITY_HAS_INIT_PRIMARY_EXCEPTION 0 +#endif + #pragma warning(push) #pragma warning(disable: 4820) //#pragma warning: N bytes padding added after data member diff --git a/source/MRViewer/CMakeLists.txt b/source/MRViewer/CMakeLists.txt index 04b9e0e332b1..39dd85983b8e 100644 --- a/source/MRViewer/CMakeLists.txt +++ b/source/MRViewer/CMakeLists.txt @@ -111,6 +111,19 @@ IF(MR_PCH) target_precompile_headers(${PROJECT_NAME} REUSE_FROM MRPch) ENDIF() +IF(MESHLIB_BUILD_MCP) + target_link_libraries(${PROJECT_NAME} PRIVATE MRMcp) + + # If we're using fastmcpp from a subdirectory, we need to add this explicitly. Not the best way to do this, we should probably find a proper solution. + IF(MESHLIB_USE_VCPKG OR APPLE) + target_include_directories(${PROJECT_NAME} PRIVATE + ${MESHLIB_THIRDPARTY_DIR}/nlohmann-json/include + ) + ENDIF() +ELSE() + target_compile_definitions(${PROJECT_NAME} PRIVATE MESHLIB_NO_MCP) +ENDIF() + file(GLOB JSONS "*.json") file(GLOB AWESOME_FONTS "${MESHLIB_THIRDPARTY_DIR}/fontawesome-free/*.ttf") file(GLOB IMGUI_FONTS "${MESHLIB_THIRDPARTY_DIR}/imgui/misc/fonts/*.ttf") diff --git a/source/MRViewer/MRSetupViewer.cpp b/source/MRViewer/MRSetupViewer.cpp index aaf95506eb25..94dec311540d 100644 --- a/source/MRViewer/MRSetupViewer.cpp +++ b/source/MRViewer/MRSetupViewer.cpp @@ -19,6 +19,10 @@ #include #endif +#ifndef MESHLIB_NO_MCP +#include "MRMcp/MRMcp.h" +#endif + namespace MR { @@ -189,4 +193,14 @@ void ViewerSetup::unloadExtendedLibraries() const #endif // ifndef __EMSCRIPTEN__ } +bool ViewerSetup::setupMcp() const +{ + #ifndef MESHLIB_NO_MCP + Mcp::getDefaultServer().setRunning( true ); + return true; + #else + return false; + #endif +} + } //namespace MR diff --git a/source/MRViewer/MRSetupViewer.h b/source/MRViewer/MRSetupViewer.h index 3b63c3a4e506..add0fdd6a90a 100644 --- a/source/MRViewer/MRSetupViewer.h +++ b/source/MRViewer/MRSetupViewer.h @@ -43,6 +43,10 @@ class MRVIEWER_CLASS ViewerSetup /// free all libraries loaded in setupExtendedLibraries() MRVIEWER_API virtual void unloadExtendedLibraries() const; + /// Launch the MCP server. Append this to the `CommandLoop` instead of calling immediately. + /// Returns false if the MCP support is not compiled in. + MRVIEWER_API virtual bool setupMcp() const; + // functor to setup custom log sink, i.e. sending logs to web std::function setupCustomLogSink; diff --git a/source/MRViewer/MRUITestEngineControl.cpp b/source/MRViewer/MRUITestEngineControl.cpp new file mode 100644 index 000000000000..8c8254bb11f9 --- /dev/null +++ b/source/MRViewer/MRUITestEngineControl.cpp @@ -0,0 +1,315 @@ +#include "MRUITestEngineControl.h" + +#include "MRMesh/MRMeshFwd.h" +#include "MRPch/MRFmt.h" +#include "MRViewer/MRUITestEngine.h" + +#include +#include +#include +#include + +namespace MR::UI::TestEngine::Control +{ + +static std::string listKeys( const MR::UI::TestEngine::GroupEntry& group ) +{ + std::string ret; + bool first = true; + for ( const auto& elem : group.elems ) + { + if ( first ) + first = false; + else + ret += ", "; + ret += '`'; + ret += elem.first; + ret += '`'; + } + return ret; +} + +static const Expected findGroup( std::span path ) +{ + const TestEngine::GroupEntry* cur = &TestEngine::getRootEntry(); + for ( const auto& segment : path ) + { + auto iter = cur->elems.find( segment ); + if ( iter == cur->elems.end() ) + return unexpected( fmt::format( "No such entry: `{}`. Known entries are: {}.", segment, listKeys( *cur ) ) ); + auto ex = iter->second.getAs( segment ); + if (!ex) + return unexpected( ex.error() ); + cur = *ex; + } + return cur; +} + +std::string pathToString( const std::vector& path ) +{ + std::string pathString; + for ( const auto & s : path ) + { + if ( !pathString.empty() ) + pathString += '/'; + pathString += s; + } + return pathString; +} + +Expected> listEntries( const std::vector& path ) +{ + auto groupEx = findGroup( path ); + if ( !groupEx ) + return unexpected( groupEx.error() ); + const auto& group = **groupEx; + + std::vector ret; + ret.reserve( group.elems.size() ); + + for ( const auto& elem : group.elems ) + { + ret.push_back( { + .name = elem.first, + .type = std::visit( MR::overloaded{ + []( const TestEngine::ButtonEntry& ) { return EntryType::button; }, + []( const TestEngine::ValueEntry& e ) + { + return std::visit( MR::overloaded{ + []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueInt; }, + []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueUint; }, + []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueReal; }, + []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueString; }, + }, e.value ); + }, + []( const TestEngine::GroupEntry& ) { return EntryType::group; }, + }, elem.second.value ), + } ); + } + return ret; +} + +Expected pressButton( const std::vector& path ) +{ + if ( path.empty() ) + return unexpected( "pressButton: Empty path not allowed here." ); + + auto groupEx = findGroup( { path.data(), path.size() - 1 } ); + if ( !groupEx ) + return unexpected( groupEx.error() ); + const auto& group = **groupEx; + + auto iter = group.elems.find( path.back() ); + if ( iter == group.elems.end() ) + unexpected( fmt::format( "pressButton {}: no such entry: `{}`. Known entries are: {}.", pathToString( path ), path.back(), listKeys( group ) ) ); + + auto buttonEx = iter->second.getAs( path.back() ); + if ( !buttonEx ) + return unexpected( buttonEx.error() ); + + ( *buttonEx )->simulateClick = true; + + return {}; +} + +template +Expected> readValue( const std::vector& path ) +{ + if ( path.empty() ) + return unexpected( "readValue: Empty path not allowed here." ); + + auto groupEx = findGroup( { path.data(), path.size() - 1 } ); + if ( !groupEx ) + return unexpected( groupEx.error() ); + const auto& group = **groupEx; + + auto iter = group.elems.find( path.back() ); + if ( iter == group.elems.end() ) + return unexpected( fmt::format( "No such entry: `{}`. Known entries are: {}.", path.back(), listKeys( group ) ) ); + + auto entryEx = iter->second.getAs( path.back() ); + if ( !entryEx ) + return unexpected( entryEx.error() ); + const auto& entry = **entryEx; + + if constexpr ( std::is_same_v ) + { + if ( auto val = std::get_if>( &entry.value ) ) + { + Value ret; + ret.value = val->value; + ret.allowedValues = val->allowedValues; + return ret; + } + + return unexpected( "This isn't a string." ); + } + else + { + // Try to read with the wrong signedness first. + if constexpr ( std::is_same_v ) + { + if ( auto val = std::get_if>( &entry.value ) ) + { + // Allow if the value is not too large. + // We don't check if the max bound is too large, because it be too large by default if not specified. + + if ( val->value > std::uint64_t( std::numeric_limits::max() ) ) + return unexpected( "Attempt to read an uint64_t value as an int64_t, but the value is too large to fit into the target type. Read as uint64_t instead." ); + + Value ret; + ret.value = std::int64_t( val->value ); + ret.min = std::int64_t( std::min( val->min, std::uint64_t( std::numeric_limits::max() ) ) ); + ret.max = std::int64_t( std::min( val->max, std::uint64_t( std::numeric_limits::max() ) ) ); + return ret; + } + } + else if constexpr ( std::is_same_v ) + { + if ( auto val = std::get_if>( &entry.value ) ) + { + // Allow if the value is nonnegative, and the min bound is also nonnegative. + + if ( val->value < 0 || val->min < 0 ) + return unexpected( "Attempt to read an int64_t value as a uint64_t, but it is or can be negative. Read as int64_t instead." ); + + Value ret; + ret.value = std::uint64_t( val->value ); + ret.min = std::uint64_t( val->min ); + ret.max = std::uint64_t( val->max ); + return ret; + } + } + + if ( auto val = std::get_if>( &entry.value ) ) + { + Value ret; + ret.value = val->value; + ret.min = val->min; + ret.max = val->max; + return ret; + } + + return unexpected( std::is_floating_point_v + ? "This isn't a floating-point value." + : "This isn't an integer." + ); + } +} + +template Expected> readValue( const std::vector& path ); +template Expected> readValue( const std::vector& path ); +template Expected> readValue( const std::vector& path ); +template Expected> readValue( const std::vector& path ); + + +template +Expected writeValue( const std::vector& path, T value ) +{ + if ( path.empty() ) + return unexpected( "writeValue: Empty path not allowed here." ); + + auto groupEx = findGroup( { path.data(), path.size() - 1 } ); + if ( !groupEx ) + return unexpected( groupEx.error() ); + const auto& group = **groupEx; + + auto iter = group.elems.find( path.back() ); + if ( iter == group.elems.end() ) + return unexpected( fmt::format( "writeValue {}: no such entry: `{}`. Known entries are: {}.", pathToString( path ), path.back(), listKeys( group ) ) ); + + auto entryEx = iter->second.getAs( path.back() ); + if ( !entryEx ) + return unexpected( entryEx.error() ); + const auto& entry = **entryEx; + + auto writeValueOfCorrectType = [&entry, &path]( auto fixedValue ) -> Expected + { + using U = decltype( fixedValue ); + auto &target = std::get>( entry.value ); + + // Validate the value. + if constexpr ( std::is_same_v ) + { + if ( target.allowedValues && std::find( target.allowedValues->begin(), target.allowedValues->end(), fixedValue ) == target.allowedValues->end() ) + { + std::string allowedValuesStr; + bool first = true; + for ( const auto& allowedValue : *target.allowedValues ) + { + if ( !std::exchange( first, false ) ) + allowedValuesStr += ", "; + + allowedValuesStr += '`'; + allowedValuesStr += allowedValue; + allowedValuesStr += '`'; + } + + return unexpected( fmt::format( "writeValue {}: string `{}` is not allowed here. Allowed values: {}.", pathToString( path ), fixedValue, allowedValuesStr ) ); + } + } + else + { + if ( fixedValue < target.min ) + return unexpected( fmt::format( "writeValue {}: the specified value {} is less than the min bound {}.", pathToString( path ), fixedValue, target.min ) ); + if ( fixedValue > target.max ) + return unexpected( fmt::format( "writeValue {}: the specified value {} is more than the max bound {}.", pathToString( path ), fixedValue, target.max ) ); + } + + std::get>( entry.value ).simulatedValue = std::move( fixedValue ); + + return {}; + }; + + if constexpr ( std::is_same_v ) + { + if ( std::holds_alternative>( entry.value ) ) + return writeValueOfCorrectType( std::move( value ) ); + else + return unexpected( fmt::format( "writeValue: `{}` is a number, but received a string.", pathToString( path ) ) ); + } + else if constexpr ( std::is_same_v ) + { + return std::visit( MR::overloaded{ + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return unexpected( fmt::format( "writeValue: `{}` is a string, but received a number.", pathToString( path ) ) ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return writeValueOfCorrectType( value ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return unexpected( fmt::format( "writeValue: `{}` is an integer, but received a fractional number.", pathToString( path ) ) ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return unexpected( fmt::format( "writeValue: `{}` is an integer, but received a fractional number.", pathToString( path ) ) ); }, + }, entry.value ); + } + else if constexpr ( std::is_same_v ) + { + return std::visit( MR::overloaded{ + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return unexpected( fmt::format( "writeValue: `{}` is a string, but received a number.", pathToString( path ) ) ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return writeValueOfCorrectType( double( value ) ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return writeValueOfCorrectType( value ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected + { + if ( value < 0 ) + return unexpected( fmt::format( "writeValue: `{}` is unsigned, but received a negative number.", pathToString( path ) ) ); + return writeValueOfCorrectType( std::uint64_t( value ) ); + }, + }, entry.value ); + } + else if constexpr ( std::is_same_v ) + { + return std::visit( MR::overloaded{ + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return unexpected( fmt::format( "writeValue: `{}` is a string, but received a number.", pathToString( path ) ) ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return writeValueOfCorrectType( double( value ) ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected { return writeValueOfCorrectType( value ); }, + [&]( const TestEngine::ValueEntry::Value& ) -> Expected + { + if ( value > std::uint64_t( std::numeric_limits::max() ) ) + return unexpected( fmt::format( "writeValue: `{}` is signed, but received an unsigned integer large enough to not be representable as `int64_t`.", pathToString( path ) ) ); + return writeValueOfCorrectType( std::int64_t( value ) ); + }, + }, entry.value ); + } +} + +template Expected writeValue( const std::vector& path, std::int64_t value ); +template Expected writeValue( const std::vector& path, std::uint64_t value ); +template Expected writeValue( const std::vector& path, double value ); +template Expected writeValue( const std::vector& path, std::string value ); + +} // namespace MR::UI::TestEngine::Control diff --git a/source/MRViewer/MRUITestEngineControl.h b/source/MRViewer/MRUITestEngineControl.h new file mode 100644 index 000000000000..20a0286b7fcc --- /dev/null +++ b/source/MRViewer/MRUITestEngineControl.h @@ -0,0 +1,101 @@ +#pragma once + +#include "exports.h" +#include "MRMesh/MRExpected.h" + +#include +#include +#include +#include + +namespace MR::UI::TestEngine::Control +{ + +// Most changes in this file must be synced with: +// * Python: `source/mrviewerpy/MRPythonUiInteraction.cpp`. +// * MCP: `source/MRViewer/MRViewerMcp.cpp`. + +enum class EntryType +{ + button, + group, + valueInt, + valueUint, + valueReal, + valueString, +}; + +[[nodiscard]] inline std::string_view toString( EntryType type ) +{ + const char* ret = nullptr; + switch ( type ) + { + case Control::EntryType::button: ret = "button"; break; + case Control::EntryType::valueInt: ret = "valueInt"; break; + case Control::EntryType::valueUint: ret = "valueUint"; break; + case Control::EntryType::valueReal: ret = "valueReal"; break; + case Control::EntryType::valueString: ret = "valueString"; break; + case Control::EntryType::group: ret = "group"; break; + } + assert( ret && "Unknown enum." ); + if ( !ret ) + ret = "??"; + return ret; +} + +struct TypedEntry +{ + std::string name; + EntryType type; +}; + +// Returns the elements of `path` combined into a single string. +[[nodiscard]] MRVIEWER_API std::string pathToString( const std::vector& path ); + +// Returns the contents of `path`, or an error if the path is wrong. +[[nodiscard]] MRVIEWER_API Expected> listEntries( const std::vector& path ); + +// Presses the button at this path, or returns an error. +MRVIEWER_API Expected pressButton( const std::vector& path ); + +// Read/write values: (drags, sliders, etc) + +template +struct Value +{ + T value = 0; + T min = 0; + T max = 0; +}; +template <> +struct Value +{ + std::string value; + + std::optional> allowedValues; +}; +using ValueInt = Value; +using ValueUint = Value; +using ValueReal = Value; +using ValueString = Value; + +// Returns the value at the `path`, or returns an error if the path or type is wrong. +template +MRVIEWER_API Expected> readValue( const std::vector& path ); + +extern template MRVIEWER_API Expected> readValue( const std::vector& path ); +extern template MRVIEWER_API Expected> readValue( const std::vector& path ); +extern template MRVIEWER_API Expected> readValue( const std::vector& path ); +extern template MRVIEWER_API Expected> readValue( const std::vector& path ); + +// Modifies the value at the `path`, or returns an error if the path, type or value are wrong. +template +MRVIEWER_API Expected writeValue( const std::vector& path, T value ); + +extern template MRVIEWER_API Expected writeValue( const std::vector& path, std::int64_t value ); +extern template MRVIEWER_API Expected writeValue( const std::vector& path, std::uint64_t value ); +extern template MRVIEWER_API Expected writeValue( const std::vector& path, double value ); +extern template MRVIEWER_API Expected writeValue( const std::vector& path, std::string value ); + + +} // namespace MR::UI::TestEngine::Control diff --git a/source/MRViewer/MRViewer.cpp b/source/MRViewer/MRViewer.cpp index a4b9e8cd0bba..905aa8e774a4 100644 --- a/source/MRViewer/MRViewer.cpp +++ b/source/MRViewer/MRViewer.cpp @@ -226,14 +226,14 @@ static void glfw_window_pos( GLFWwindow* /*window*/, int xPos, int yPos ) } ); // It is necessary to redraw the contents of the window when moving the window in Windows OS - // + // // (on Windows) The glfw_window_pos callback is called, but glfwWaitEvents does not pass, // and event queue processing is not performed until the end of the move. // For this reason, draw is called outside of EventQueue. - // + // // "On some platforms, a window move, resize or menu operation will cause event processing to block. This is due to how event processing is designed on those platforms" // https://www.glfw.org/docs/latest/group__window.html#ga37bd57223967b4211d60ca1a0bf3c832 - // + // // https://stackoverflow.com/questions/71243906/glfw-window-poll-events-lag #ifdef _WIN32 viewer->draw( true ); @@ -393,6 +393,7 @@ int launchDefaultViewer( const Viewer::LaunchParams& params, const ViewerSetup& CommandLoop::appendCommand( [&] () { setup.setupExtendedLibraries(); + setup.setupMcp(); }, CommandLoop::StartPosition::AfterSplashAppear ); int res = 0; @@ -634,10 +635,10 @@ int Viewer::launch( const LaunchParams& params ) isAnimating = params.isAnimating; animationMaxFps = params.animationMaxFps; experimentalFeatures = params.developerFeatures; - + bool defaultMultiViewport = Config::instance().getBool( cDefaultMultiViewportKey, true ); launchParams_.multiViewport = defaultMultiViewport && params.multiViewport; - + auto res = launchInit_( params ); if ( res != EXIT_SUCCESS ) return res; @@ -914,7 +915,7 @@ int Viewer::launchInit_( const LaunchParams& params ) params.splashWindow->start(); continueTime = std::chrono::steady_clock::now() + std::chrono::duration( params.splashWindow->minimumTimeSec() ); } - + CommandLoop::setState( CommandLoop::StartPosition::AfterSplashAppear ); CommandLoop::processCommands(); diff --git a/source/MRViewer/MRViewer.vcxproj b/source/MRViewer/MRViewer.vcxproj index e2d051abfc8b..7ff63089ef77 100644 --- a/source/MRViewer/MRViewer.vcxproj +++ b/source/MRViewer/MRViewer.vcxproj @@ -159,6 +159,8 @@ + + @@ -339,6 +341,7 @@ + @@ -473,6 +476,9 @@ {7cc4f0fe-ace6-4441-9dd7-296066b6d69f} + + {c8250f26-e01d-4a63-98cd-68069d818080} + 15.0 @@ -516,7 +522,7 @@ true true $(ProjectDir)..\MRPch\MRPch.h - %(AdditionalIncludeDirectories);$(ProjectDir)..\..\thirdparty;$(ProjectDir)\..\..\thirdparty\imgui\ + %(AdditionalIncludeDirectories);$(ProjectDir)..\..\thirdparty;$(ProjectDir)\..\..\thirdparty\imgui\;..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include $(ProjectDir)..\MRPch\MRPch.h $(SolutionDir)TempOutput\MRPch\$(Platform)\$(Configuration)\MRPch.pch @@ -543,7 +549,7 @@ true $(ProjectDir)..\MRPch\MRPch.h /bigobj %(AdditionalOptions) - %(AdditionalIncludeDirectories);$(ProjectDir)..\..\thirdparty;$(ProjectDir)\..\..\thirdparty\imgui\ + %(AdditionalIncludeDirectories);$(ProjectDir)..\..\thirdparty;$(ProjectDir)\..\..\thirdparty\imgui\;..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include $(ProjectDir)..\MRPch\MRPch.h $(SolutionDir)TempOutput\MRPch\$(Platform)\$(Configuration)\MRPch.pch @@ -562,4 +568,4 @@ - \ No newline at end of file + diff --git a/source/MRViewer/MRViewer.vcxproj.filters b/source/MRViewer/MRViewer.vcxproj.filters index 1ad7dfd214b2..c1bdf077eb5e 100644 --- a/source/MRViewer/MRViewer.vcxproj.filters +++ b/source/MRViewer/MRViewer.vcxproj.filters @@ -86,6 +86,9 @@ {29097c41-4b59-45fe-9b71-20f6095be069} + + {a9f6cb49-a0c1-4ee7-bcef-760c93bdc757} + @@ -382,6 +385,9 @@ UIStyle + + UIStyle + Viewer @@ -523,6 +529,9 @@ Localization + + AI + @@ -882,6 +891,9 @@ UIStyle + + UIStyle + Viewer @@ -1038,6 +1050,9 @@ Localization + + AI + @@ -1258,4 +1273,4 @@ resource\independent_icons\X3 - \ No newline at end of file + diff --git a/source/MRViewer/MRViewerMcp.cpp b/source/MRViewer/MRViewerMcp.cpp new file mode 100644 index 000000000000..d20e39264dbf --- /dev/null +++ b/source/MRViewer/MRViewerMcp.cpp @@ -0,0 +1,178 @@ +#ifndef MESHLIB_NO_MCP + +#include "MRMcp/MRMcp.h" +#include "MRMesh/MROnInit.h" +#include "MRViewer/MRCommandLoop.h" +#include "MRViewer/MRUITestEngineControl.h" +#include "MRViewer/MRViewer.h" + +namespace MR +{ + +static void skipFramesAfterInput() +{ + for ( int i = 0; i < MR::getViewerInstance().forceRedrawMinimumIncrementAfterEvents; ++i ) + MR::CommandLoop::runCommandFromGUIThread( [] {} ); // Wait a few frames. +} + +static nlohmann::json mcpToolListUiEntries( const nlohmann::json& args ) +{ + std::vector list; + MR::CommandLoop::runCommandFromGUIThread( [&] + { + auto ex = UI::TestEngine::Control::listEntries( args.at( "path" ).get>() ); + if ( !ex ) + throw std::runtime_error( ex.error() ); + list = std::move( *ex ); + } ); + + nlohmann::json ret = nlohmann::json::array(); + for ( const auto& elem : list ) + { + std::string typeStr; + switch ( elem.type ) + { + case UI::TestEngine::Control::EntryType::button: typeStr = "button"; break; + case UI::TestEngine::Control::EntryType::group: typeStr = "group"; break; + case UI::TestEngine::Control::EntryType::valueInt: typeStr = "int"; break; + case UI::TestEngine::Control::EntryType::valueUint: typeStr = "uint"; break; + case UI::TestEngine::Control::EntryType::valueReal: typeStr = "float"; break; // Hopefully "float" is more clear to LLMs than "real". The actual underlying type is `double`. + case UI::TestEngine::Control::EntryType::valueString: typeStr = "string"; break; + } + + assert( !typeStr.empty() ); + if ( typeStr.empty() ) + typeStr = "invalid"; + + ret.push_back( nlohmann::json::object( { + { "name", elem.name }, + { "type", std::move( typeStr ) }, + } ) ); + } + + return nlohmann::json::object( { { "result", ret } } ); +} + +static nlohmann::json mcpToolPressButton( const nlohmann::json& args ) +{ + MR::CommandLoop::runCommandFromGUIThread( [&] + { + auto ex = UI::TestEngine::Control::pressButton( args.at( "path" ).get>() ); + if ( !ex ) + throw std::runtime_error( ex.error() ); + } ); + skipFramesAfterInput(); + + return nlohmann::json::object(); +} + +template +static nlohmann::json mcpToolReadValue( const nlohmann::json& args ) +{ + UI::TestEngine::Control::Value value; + MR::CommandLoop::runCommandFromGUIThread( [&] + { + auto ex = UI::TestEngine::Control::readValue( args.at( "path" ).get>() ); + if ( !ex ) + throw std::runtime_error( ex.error() ); + value = std::move( *ex ); + } ); + + nlohmann::json ret = nlohmann::json::object(); + ret["value"] = value.value; + if constexpr ( std::is_same_v ) + { + if ( value.allowedValues ) + ret["allowedValues"] = *value.allowedValues; + } + else + { + ret["min"] = value.min; + ret["max"] = value.max; + } + + return ret; +} + +template +static nlohmann::json mcpToolWriteValue( const nlohmann::json& args ) +{ + MR::CommandLoop::runCommandFromGUIThread( [&] + { + auto ex = UI::TestEngine::Control::writeValue( args.at( "path" ).get>(), T( args.at( "value" ) ) ); + if ( !ex ) + throw std::runtime_error( ex.error() ); + } ); + skipFramesAfterInput(); + + return nlohmann::json::object(); +} + +MR_ON_INIT{ + Mcp::Server& server = Mcp::getDefaultServer(); + + server.addTool( + /*id*/"ui.listEntries", + /*name*/"List UI entries", + /*desc*/"Returns the list of UI elements at the given path. The elements form a tree. Pass an empty array to get the top-level elements. Each element is described by a string. The path parameter describes the path from the root node to a specific element. Only elements of type `group` can have sub-elements.", + /*input_schema*/Mcp::Schema::Object{}.addMember( "path", Mcp::Schema::Array( Mcp::Schema::String{} ) ), + /*output_schema*/Mcp::Schema::Array( Mcp::Schema::Object{}.addMember( "name", Mcp::Schema::String{} ).addMember( "type", Mcp::Schema::String{} ) ), + /*func*/mcpToolListUiEntries + ); + + server.addTool( + /*id*/"ui.pressButton", + /*name*/"Press button", + /*desc*/"Presses the button at the given path.", + /*input_schema*/Mcp::Schema::Object{}.addMember( "path", Mcp::Schema::Array( Mcp::Schema::String{} ) ), + /*output_schema*/Mcp::Schema::Empty{}, + /*func*/mcpToolPressButton + ); + + auto handleValueType = [&]( const std::string& typeName ) + { + server.addTool( + /*id*/"ui.readValue" + typeName, + /*name*/"Read " + typeName + " value", + /*desc*/"Reads the value at the given path, of type `" + typeName + "`." + + ( + std::is_same_v + ? + " If the result contains an array called `allowedValues`, then when assigning a new value using `ui.writeValue" + typeName + "`, it must match one of the strings listed in `allowedValues`." + : + " When assigning a new value using `ui.writeValue" + typeName + "`, it must be between `min` and `max` inclusive." + ), + /*input_schema*/Mcp::Schema::Object{}.addMember( "path", Mcp::Schema::Array( Mcp::Schema::String{} ) ), + /*output_schema*/( + std::is_same_v + ? + static_cast( + Mcp::Schema::Object{}.addMember( "value", Mcp::Schema::String{} ).addMemberOpt( "allowedValues", Mcp::Schema::Array( Mcp::Schema::String{} ) ) + ) + : + static_cast( + Mcp::Schema::Object{}.addMember( "value", Mcp::Schema::Number{} ).addMember( "min", Mcp::Schema::Number{} ).addMember( "max", Mcp::Schema::Number{} ) + ) + ), + /*func*/mcpToolReadValue + ); + + server.addTool( + /*id*/"ui.writeValue" + typeName, + /*name*/"Write " + typeName + " value", + /*desc*/"Writes the value at the given path, of type `" + typeName + "`. You can call `ui.readValue" + typeName + "` before this to know what values are allowed.", + /*input_schema*/Mcp::Schema::Object{}.addMember( "path", Mcp::Schema::Array( Mcp::Schema::String{} ) ).addMember( "value", std::is_same_v ? static_cast( Mcp::Schema::String{} ) : static_cast( Mcp::Schema::Number{} ) ), + /*output_schema*/Mcp::Schema::Empty{}, + /*func*/mcpToolWriteValue + ); + }; + + handleValueType.operator()( "Int" ); + handleValueType.operator()( "Uint" ); + handleValueType.operator()( "Real" ); + handleValueType.operator()( "String" ); +}; // MR_ON_INIT + +} // namespace MR + +#endif diff --git a/source/MeshLib.sln b/source/MeshLib.sln index 54ec44ff4fa2..967ecf9668d2 100644 --- a/source/MeshLib.sln +++ b/source/MeshLib.sln @@ -62,6 +62,10 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MREmbeddedPython", "MREmbed EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MRTestCuda", "MRTestCuda\MRTestCuda.vcxproj", "{FFB8D063-FF1E-4F18-8479-249B36714EF7}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "fastmcpp", "fastmcpp\fastmcpp.vcxproj", "{7853AEC9-A364-4587-89AE-FAA9A463E6ED}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MRMcp", "MRMcp\MRMcp.vcxproj", "{C8250F26-E01D-4A63-98CD-68069D818080}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -152,6 +156,14 @@ Global {FFB8D063-FF1E-4F18-8479-249B36714EF7}.Debug|x64.Build.0 = Debug|x64 {FFB8D063-FF1E-4F18-8479-249B36714EF7}.Release|x64.ActiveCfg = Release|x64 {FFB8D063-FF1E-4F18-8479-249B36714EF7}.Release|x64.Build.0 = Release|x64 + {7853AEC9-A364-4587-89AE-FAA9A463E6ED}.Debug|x64.ActiveCfg = Debug|x64 + {7853AEC9-A364-4587-89AE-FAA9A463E6ED}.Debug|x64.Build.0 = Debug|x64 + {7853AEC9-A364-4587-89AE-FAA9A463E6ED}.Release|x64.ActiveCfg = Release|x64 + {7853AEC9-A364-4587-89AE-FAA9A463E6ED}.Release|x64.Build.0 = Release|x64 + {C8250F26-E01D-4A63-98CD-68069D818080}.Debug|x64.ActiveCfg = Debug|x64 + {C8250F26-E01D-4A63-98CD-68069D818080}.Debug|x64.Build.0 = Debug|x64 + {C8250F26-E01D-4A63-98CD-68069D818080}.Release|x64.ActiveCfg = Release|x64 + {C8250F26-E01D-4A63-98CD-68069D818080}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -178,6 +190,8 @@ Global {5612E480-6980-4242-9039-BE367F4ECBF0} = {DAEF3759-BD96-475D-AA71-96ACC5279E43} {E0202297-EDB2-4CDC-9CD0-8921EFF08DA0} = {AE8B4895-7920-4AD3-B554-C858A08B1680} {FFB8D063-FF1E-4F18-8479-249B36714EF7} = {E0BE85ED-C366-40EF-8BDE-70E1EDC8860F} + {7853AEC9-A364-4587-89AE-FAA9A463E6ED} = {AE8B4895-7920-4AD3-B554-C858A08B1680} + {C8250F26-E01D-4A63-98CD-68069D818080} = {AE8B4895-7920-4AD3-B554-C858A08B1680} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6F7912D7-5687-4CBB-828B-1BEDD18B8249} diff --git a/source/fastmcpp/CMakeLists.txt b/source/fastmcpp/CMakeLists.txt new file mode 100644 index 000000000000..2aa0a2e78f0f --- /dev/null +++ b/source/fastmcpp/CMakeLists.txt @@ -0,0 +1,20 @@ +# This file exists so we can tweak some settings for Fastmcpp. +# If we were to `add_subdirectory` it directly from `CMakeLists.txt`, we would have to modify the global CXX flags, which is uncool. + +# Sync those flags with `scripts/build_thirdparty.sh`. + +IF(NOT WIN32) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC") +ENDIF() + +IF(MSVC) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /wd4099 /wd4100 /wd4242 /wd4244 /wd4355 /wd4456 /wd4458 /wd4464 /wd4505 /wd4702 /wd5204 /wd5220 /wd5233 /wd5245") +ELSE() + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-unused-parameter -Wno-error") +ENDIF() + +set(FASTMCPP_BUILD_TESTS OFF CACHE BOOL "Build tests") +set(FASTMCPP_BUILD_EXAMPLES OFF CACHE BOOL "Build examples") +set(FASTMCPP_FETCH_CURL OFF CACHE BOOL "Fetch and build libcurl statically for POST streaming") + +add_subdirectory(${MESHLIB_THIRDPARTY_DIR}/fastmcpp fastmcpp) diff --git a/source/fastmcpp/fastmcpp.vcxproj b/source/fastmcpp/fastmcpp.vcxproj new file mode 100644 index 000000000000..650d843419d8 --- /dev/null +++ b/source/fastmcpp/fastmcpp.vcxproj @@ -0,0 +1,210 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + + + + + + $(IntDir)client\ + + + $(IntDir)client\ + + + $(IntDir)client\ + + + $(IntDir)internal\ + + + $(IntDir)mcp\ + + + $(IntDir)mcp\ + + + $(IntDir)prompts\ + + + $(IntDir)prompts\ + + + $(IntDir)providers\ + + + $(IntDir)providers\ + + + $(IntDir)providers\ + + + $(IntDir)providers\transforms\ + + + $(IntDir)providers\transforms\ + + + $(IntDir)providers\transforms\ + + + $(IntDir)providers\transforms\ + + + $(IntDir)providers\transforms\ + + + $(IntDir)providers\transforms\ + + + $(IntDir)resources\ + + + $(IntDir)resources\ + + + $(IntDir)resources\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)server\ + + + $(IntDir)tools\ + + + $(IntDir)tools\ + + + $(IntDir)util\ + + + $(IntDir)util\ + + + $(IntDir)util\ + + + + 15.0 + {7853AEC9-A364-4587-89AE-FAA9A463E6ED} + Win32Proj + fastmcpp + + + + StaticLibrary + true + Unicode + + + StaticLibrary + false + false + Unicode + + + + + + + + + + + + + + + + + + NotUsing + EnableAllWarnings + Disabled + true + _DEBUG;%(PreprocessorDefinitions) + true + true + %(AdditionalIncludeDirectories);..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include + 4099;4100;4242;4244;4355;4456;4458;4464;4505;4702;5204;5220;5233;5245;5267;%(DisableSpecificWarnings) + + + Console + true + %(AdditionalDependencies) + + + + + + + + + NotUsing + EnableAllWarnings + MaxSpeed + true + true + true + NDEBUG;%(PreprocessorDefinitions) + true + true + /bigobj %(AdditionalOptions) + %(AdditionalIncludeDirectories);..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include + 4099;4100;4242;4244;4355;4456;4458;4464;4505;4702;5204;5220;5233;5245;5267;%(DisableSpecificWarnings) + + + Console + true + true + true + + + + + + diff --git a/source/mrviewerpy/MRPythonUiInteraction.cpp b/source/mrviewerpy/MRPythonUiInteraction.cpp index 8b381ee0f1ac..a074dd13393c 100644 --- a/source/mrviewerpy/MRPythonUiInteraction.cpp +++ b/source/mrviewerpy/MRPythonUiInteraction.cpp @@ -1,6 +1,6 @@ #include "MRPython/MRPython.h" #include "MRViewer/MRPythonAppendCommand.h" -#include "MRViewer/MRUITestEngine.h" +#include "MRViewer/MRUITestEngineControl.h" #include "MRViewer/MRViewer.h" #include "MRPch/MRFmt.h" #include "MRPch/MRSpdlog.h" @@ -9,365 +9,82 @@ #include -namespace TestEngine = MR::UI::TestEngine; +namespace Control = MR::UI::TestEngine::Control; -namespace +MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiEntry, Control::TypedEntry ) +MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueInt, Control::ValueInt ) +MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueUint, Control::ValueUint ) +MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueReal, Control::ValueReal ) +MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueString, Control::ValueString ) + +MR_ADD_PYTHON_CUSTOM_DEF( mrviewerpy, UiEntry, [] ( pybind11::module_& m ) { - enum class EntryType - { - button, - group, - valueInt, - valueUint, - valueReal, - valueString, - other, - // Don't forget to add new values to `pybind11::enum_` below! - }; + // Not using `MR_ADD_PYTHON_VEC(..., TypedEntry)` here, I don't seem to need any of custom functions it provides. + + pybind11::enum_( m, "UiEntryType", "UI entry type enum." ) + .value( "button", Control::EntryType::button ) + .value( "group", Control::EntryType::group ) + .value( "valueInt", Control::EntryType::valueInt ) + .value( "valueUint", Control::EntryType::valueUint ) + .value( "valueReal", Control::EntryType::valueReal ) + .value( "valueString", Control::EntryType::valueString ) + ; - struct TypedEntry - { - std::string name; - EntryType type; - }; + MR_PYTHON_CUSTOM_CLASS( UiValueInt ).def_readonly( "value", &Control::ValueInt::value ).def_readonly( "min", &Control::ValueInt::min ).def_readonly( "max", &Control::ValueInt::max ); + MR_PYTHON_CUSTOM_CLASS( UiValueUint ).def_readonly( "value", &Control::ValueUint::value ).def_readonly( "min", &Control::ValueUint::min ).def_readonly( "max", &Control::ValueUint::max ); + MR_PYTHON_CUSTOM_CLASS( UiValueReal ).def_readonly( "value", &Control::ValueReal::value ).def_readonly( "min", &Control::ValueReal::min ).def_readonly( "max", &Control::ValueReal::max ); + MR_PYTHON_CUSTOM_CLASS( UiValueString ).def_readonly( "value", &Control::ValueString::value ).def_readonly( "allowed", &Control::ValueString::allowedValues ); - std::string listKeys( const MR::UI::TestEngine::GroupEntry& group ) - { - std::string ret; - bool first = true; - for ( const auto& elem : group.elems ) - { - if ( first ) - first = false; - else - ret += ", "; - ret += '`'; - ret += elem.first; - ret += '`'; - } - return ret; - } - - const TestEngine::GroupEntry& findGroup( std::span path ) - { - const TestEngine::GroupEntry* cur = &TestEngine::getRootEntry(); - for ( const auto& segment : path ) + MR_PYTHON_CUSTOM_CLASS( UiEntry ) + .def_readonly( "name", &Control::TypedEntry::name ) + .def_readonly( "type", &Control::TypedEntry::type ) + .def("__repr__", []( const Control::TypedEntry& e ) { - auto iter = cur->elems.find( segment ); - if ( iter == cur->elems.end() ) - throw std::runtime_error( fmt::format( "No such entry: `{}`. Known entries are: {}.", segment, listKeys( *cur ) ) ); - cur = MR::expectedValueOrThrow( iter->second.getAs( segment ) ); - } - return *cur; - } + return fmt::format( "", e.name, toString( e.type ) ); + } ) + ; +} ) - // Not using `MR_ADD_PYTHON_VEC` here, I don't seem to need any of custom functions it provides. - std::vector listEntries( const std::vector& path ) +MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiListEntries, + []( const std::vector& path ) { - std::vector ret; - MR::CommandLoop::runCommandFromGUIThread( [&] - { - const auto& group = findGroup( path ); - ret.reserve( group.elems.size() ); - for ( const auto& elem : group.elems ) - { - ret.push_back( { - .name = elem.first, - .type = std::visit( MR::overloaded{ - []( const TestEngine::ButtonEntry& ) { return EntryType::button; }, - []( const TestEngine::ValueEntry& e ) - { - return std::visit( MR::overloaded{ - []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueInt; }, - []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueUint; }, - []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueReal; }, - []( const TestEngine::ValueEntry::Value& ){ return EntryType::valueString; }, - }, e.value ); - }, - []( const TestEngine::GroupEntry& ) { return EntryType::group; }, - []( const auto& ) { return EntryType::other; }, - }, elem.second.value ), - } ); - } - } ); + std::vector ret; + MR::CommandLoop::runCommandFromGUIThread( [&]{ ret = MR::expectedValueOrThrow( MR::UI::TestEngine::Control::listEntries( path ) ); } ); return ret; - } - - static std::string stringVectorToString( const std::vector& path ) - { - std::string pathString; - for ( const auto & s : path ) - { - if ( !pathString.empty() ) - pathString += '/'; - pathString += s; - } - return pathString; - } - - void pressButton( const std::vector& path ) + }, + "List existing UI entries at the specified path.\n" + "Pass an empty list to see top-level groups.\n" + "Add group name to the end of the vector to see its contents.\n" + "When you find the button you need, pass it to `uiPressButton()`." +) +MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiPressButton, + []( const std::vector& path ) { - if ( path.empty() ) - throw std::runtime_error( "pressButton: empty path not allowed here." ); - const std::string pathString = stringVectorToString( path ); MR::CommandLoop::runCommandFromGUIThread( [&] { - spdlog::info( "pressButton {}: frame {}", pathString, MR::getViewerInstance().getTotalFrames() ); - - auto& group = findGroup( { path.data(), path.size() - 1 } ); - auto iter = group.elems.find( path.back() ); - if ( iter == group.elems.end() ) - throw std::runtime_error( fmt::format( "pressButton {}: no such entry: `{}`. Known entries are: {}.", pathString, path.back(), listKeys( group ) ) ); - MR::expectedValueOrThrow( iter->second.getAs( path.back() ) )->simulateClick = true; + spdlog::info( "pressButton {}: frame {}", MR::UI::TestEngine::Control::pathToString( path ), MR::getViewerInstance().getTotalFrames() ); + MR::expectedValueOrThrow( MR::UI::TestEngine::Control::pressButton( path ) ); } ); for ( int i = 0; i < MR::getViewerInstance().forceRedrawMinimumIncrementAfterEvents; ++i ) - MR::CommandLoop::runCommandFromGUIThread( [] {} ); // wait frame - } - - // Read/write values: (drags, sliders, etc) - - template - struct Value - { - T value = 0; - T min = 0; - T max = 0; - }; - template <> - struct Value - { - std::string value; - - std::optional> allowedValues; - }; - using ValueInt = Value; - using ValueUint = Value; - using ValueReal = Value; - using ValueString = Value; + MR::CommandLoop::runCommandFromGUIThread( [] {} ); // Wait a few frames. + }, + "Simulate a button click. Use `uiListEntries()` to find button names." +) +namespace +{ template - Value readValue( const std::vector& path ) + Control::Value readValue( const std::vector& path ) { - if ( path.empty() ) - throw std::runtime_error( "Empty path not allowed here." ); - Value ret; - MR::pythonAppendOrRun( [&] + Control::Value ret; + MR::CommandLoop::runCommandFromGUIThread( [&] { - const auto& group = findGroup( { path.data(), path.size() - 1 } ); - auto iter = group.elems.find( path.back() ); - if ( iter == group.elems.end() ) - throw std::runtime_error( fmt::format( "No such entry: `{}`. Known entries are: {}.", path.back(), listKeys( group ) ) ); - const auto& entry = *MR::expectedValueOrThrow( iter->second.getAs( path.back() ) ); - - if constexpr ( std::is_same_v ) - { - if ( auto val = std::get_if>( &entry.value ) ) - { - ret.value = val->value; - ret.allowedValues = val->allowedValues; - return; - } - - throw std::runtime_error( "This isn't a string." ); - } - else - { - // Try to read with the wrong signedness first. - if constexpr ( std::is_same_v ) - { - if ( auto val = std::get_if>( &entry.value ) ) - { - // Allow if the value is not too large. - // We don't check if the max bound is too large, because it be too large by default if not specified. - - if ( val->value > std::uint64_t( std::numeric_limits::max() ) ) - throw std::runtime_error( "Attempt to read an uint64_t value as an int64_t, but the value is too large to fit into the target type. Read as uint64_t instead." ); - ret.value = std::int64_t( val->value ); - ret.min = std::int64_t( std::min( val->min, std::uint64_t( std::numeric_limits::max() ) ) ); - ret.max = std::int64_t( std::min( val->max, std::uint64_t( std::numeric_limits::max() ) ) ); - return; - } - } - else if constexpr ( std::is_same_v ) - { - if ( auto val = std::get_if>( &entry.value ) ) - { - // Allow if the value is nonnegative, and the min bound is also nonnegative. - - if ( val->value < 0 || val->min < 0 ) - throw std::runtime_error( "Attempt to read an int64_t value as a uint64_t, but it is or can be negative. Read as int64_t instead." ); - ret.value = std::uint64_t( val->value ); - ret.min = std::uint64_t( val->min ); - ret.max = std::uint64_t( val->max ); - return; - } - } - - if ( auto val = std::get_if>( &entry.value ) ) - { - ret.value = val->value; - ret.min = val->min; - ret.max = val->max; - return; - } - - throw std::runtime_error( std::is_floating_point_v - ? "This isn't a floating-point value." - : "This isn't an integer." - ); - } + ret = MR::expectedValueOrThrow( Control::readValue( path ) ); } ); return ret; } - - template - void writeValue( const std::vector& path, T value ) - { - if constexpr ( WarnDeprecated ) - std::fprintf(stderr, "This function is deprecated, please use the overloaded `uiWriteValue()` instead."); - - if ( path.empty() ) - throw std::runtime_error( "writeValue: empty path not allowed here." ); - - const std::string pathString = stringVectorToString( path ); - spdlog::info( "writeValue {} = {}, frame {}", pathString, value, MR::getViewerInstance().getTotalFrames() ); - - MR::pythonAppendOrRun( [&] - { - const auto& group = findGroup( { path.data(), path.size() - 1 } ); - auto iter = group.elems.find( path.back() ); - if ( iter == group.elems.end() ) - throw std::runtime_error( fmt::format( "writeValue {}: no such entry: `{}`. Known entries are: {}.", pathString, path.back(), listKeys( group ) ) ); - const auto& entry = *MR::expectedValueOrThrow( iter->second.getAs( path.back() ) ); - - auto writeValueOfCorrectType = [&entry, &pathString]( auto fixedValue ) - { - using U = decltype( fixedValue ); - auto &target = std::get>( entry.value ); - - // Validate the value. - if constexpr ( std::is_same_v ) - { - if ( target.allowedValues && std::find( target.allowedValues->begin(), target.allowedValues->end(), fixedValue ) == target.allowedValues->end() ) - throw std::runtime_error( fmt::format( "writeValue {}: string `{}` is not allowed here. Allowew values: {}.", pathString, fixedValue, stringVectorToString( *target.allowedValues ) ) ); - } - else - { - if ( fixedValue < target.min ) - throw std::runtime_error( fmt::format( "writeValue {}: the specified value {} is less than the min bound {}.", pathString, fixedValue, target.min ) ); - if ( fixedValue > target.max ) - throw std::runtime_error( fmt::format( "writeValue {}: the specified value {} is more than the max bound {}.", pathString, fixedValue, target.max ) ); - } - - std::get>( entry.value ).simulatedValue = std::move( fixedValue ); - }; - - if constexpr ( std::is_same_v ) - { - if ( std::holds_alternative>( entry.value ) ) - writeValueOfCorrectType( std::move( value ) ); - else - throw std::runtime_error( fmt::format( "writeValue: `{}` is a number, but received a string.", pathString ) ); - } - else if constexpr ( std::is_same_v ) - { - std::visit( MR::overloaded{ - [&]( const TestEngine::ValueEntry::Value& ){ throw std::runtime_error( fmt::format( "writeValue: `{}` is a string, but received a number.", pathString ) ); }, - [&]( const TestEngine::ValueEntry::Value& ){ writeValueOfCorrectType( value ); }, - [&]( const TestEngine::ValueEntry::Value& ){ throw std::runtime_error( fmt::format( "writeValue: `{}` is an integer, but received a fractional number.", pathString ) ); }, - [&]( const TestEngine::ValueEntry::Value& ){ throw std::runtime_error( fmt::format( "writeValue: `{}` is an integer, but received a fractional number.", pathString ) ); }, - }, entry.value ); - } - else if constexpr ( std::is_same_v ) - { - std::visit( MR::overloaded{ - [&]( const TestEngine::ValueEntry::Value& ){ throw std::runtime_error( fmt::format( "writeValue: `{}` is a string, but received a number.", pathString ) ); }, - [&]( const TestEngine::ValueEntry::Value& ){ writeValueOfCorrectType( double( value ) ); }, - [&]( const TestEngine::ValueEntry::Value& ){ writeValueOfCorrectType( value ); }, - [&]( const TestEngine::ValueEntry::Value& ) - { - if ( value < 0 ) - throw std::runtime_error( fmt::format( "writeValue: `{}` is unsigned, but received a negative number.", pathString ) ); - writeValueOfCorrectType( std::uint64_t( value ) ); - }, - }, entry.value ); - } - else if constexpr ( std::is_same_v ) - { - std::visit( MR::overloaded{ - [&]( const TestEngine::ValueEntry::Value& ){ throw std::runtime_error( fmt::format( "writeValue: `{}` is a string, but received a number.", pathString ) ); }, - [&]( const TestEngine::ValueEntry::Value& ){ writeValueOfCorrectType( double( value ) ); }, - [&]( const TestEngine::ValueEntry::Value& ){ writeValueOfCorrectType( value ); }, - [&]( const TestEngine::ValueEntry::Value& ) - { - if ( value > std::uint64_t( std::numeric_limits::max() ) ) - throw std::runtime_error( fmt::format( "writeValue: `{}` is signed, but received an unsigned integer large enough to not be representable as `int64_t`.", pathString ) ); - writeValueOfCorrectType( std::int64_t( value ) ); - }, - }, entry.value ); - } - } ); - } } -MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiEntry, TypedEntry ) -MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueInt, ValueInt ) -MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueUint, ValueUint ) -MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueReal, ValueReal ) -MR_ADD_PYTHON_CUSTOM_CLASS( mrviewerpy, UiValueString, ValueString ) - -MR_ADD_PYTHON_CUSTOM_DEF( mrviewerpy, UiEntry, [] ( pybind11::module_& m ) -{ - pybind11::enum_( m, "UiEntryType", "UI entry type enum." ) - .value( "button", EntryType::button ) - .value( "group", EntryType::group ) - .value( "valueInt", EntryType::valueInt ) - .value( "valueUint", EntryType::valueUint ) - .value( "valueReal", EntryType::valueReal ) - .value( "valueString", EntryType::valueString ) - .value( "other", EntryType::other ) - ; - - MR_PYTHON_CUSTOM_CLASS( UiValueInt ).def_readonly( "value", &ValueInt::value ).def_readonly( "min", &ValueInt::min ).def_readonly( "max", &ValueInt::max ); - MR_PYTHON_CUSTOM_CLASS( UiValueUint ).def_readonly( "value", &ValueUint::value ).def_readonly( "min", &ValueUint::min ).def_readonly( "max", &ValueUint::max ); - MR_PYTHON_CUSTOM_CLASS( UiValueReal ).def_readonly( "value", &ValueReal::value ).def_readonly( "min", &ValueReal::min ).def_readonly( "max", &ValueReal::max ); - MR_PYTHON_CUSTOM_CLASS( UiValueString ).def_readonly( "value", &ValueString::value ).def_readonly( "allowed", &ValueString::allowedValues ); - - MR_PYTHON_CUSTOM_CLASS( UiEntry ) - .def_readonly( "name", &TypedEntry::name ) - .def_readonly( "type", &TypedEntry::type ) - .def("__repr__", []( const TypedEntry& e ) - { - const char* typeString = nullptr; - switch ( e.type ) - { - case EntryType::button: typeString = "button"; break; - case EntryType::valueInt: typeString = "valueInt"; break; - case EntryType::valueUint: typeString = "valueUint"; break; - case EntryType::valueReal: typeString = "valueReal"; break; - case EntryType::valueString: typeString = "valueString"; break; - case EntryType::group: typeString = "group"; break; - case EntryType::other: typeString = "other"; break; - } - assert( typeString && "Unknown enum." ); - if ( !typeString ) - typeString = "??"; - - return fmt::format( "", e.name, typeString ); - } ) - ; -} ) - -MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiListEntries, listEntries, - "List existing UI entries at the specified path.\n" - "Pass an empty list to see top-level groups.\n" - "Add group name to the end of the vector to see its contents.\n" - "When you find the button you need, pass it to `uiPressButton()`." -) -MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiPressButton, pressButton, - "Simulate a button click. Use `uiListEntries()` to find button names." -) - MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiReadValueInt, readValue, "Read a value from a drag/slider widget. This function is for signed integers." ) @@ -381,6 +98,18 @@ MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiReadValueString, readValue, "Read a value from a drag/slider widget. This function is for strings." ) +namespace +{ + template + void writeValue( const std::vector& path, T value ) + { + MR::CommandLoop::runCommandFromGUIThread( [&] + { + MR::expectedValueOrThrow( Control::writeValue( path, std::move( value ) ) ); + } ); + } +} + MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiWriteValue, writeValue, "Write a value to a drag/slider widget. This overload is for signed integers." ) @@ -394,17 +123,4 @@ MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiWriteValue, writeValue, "Write a value to a drag/slider widget. This overload is for strings." ) -// Those are deprecated and print a warning when called. Prefer the overlaoded `uiWriteValue()` above. -MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiWriteValueInt, (writeValue), - "Write a value to a drag/slider widget. This overload is for signed integers." -) -MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiWriteValueUint, (writeValue), - "Write a value to a drag/slider widget. This overload is for unsigned integers." -) -MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiWriteValueReal, (writeValue), - "Write a value to a drag/slider widget. This overload is for real numbers." -) -MR_ADD_PYTHON_FUNCTION( mrviewerpy, uiWriteValueString, (writeValue), - "Write a value to a drag/slider widget. This overload is for strings." -) // ] end deprecated diff --git a/thirdparty/cpp-httplib b/thirdparty/cpp-httplib new file mode 160000 index 000000000000..b045ee7f6b43 --- /dev/null +++ b/thirdparty/cpp-httplib @@ -0,0 +1 @@ +Subproject commit b045ee7f6b434a85fd011e96e28c6d4abfb18788 diff --git a/thirdparty/fastmcpp b/thirdparty/fastmcpp new file mode 160000 index 000000000000..be0a7f43f185 --- /dev/null +++ b/thirdparty/fastmcpp @@ -0,0 +1 @@ +Subproject commit be0a7f43f185102cd488f62bf201eb3f4360663a diff --git a/thirdparty/nlohmann-json b/thirdparty/nlohmann-json new file mode 160000 index 000000000000..394687226559 --- /dev/null +++ b/thirdparty/nlohmann-json @@ -0,0 +1 @@ +Subproject commit 3946872265598aed5a7aea68cad4d9d1f168bd4b