diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 203f3c8..4ad7bd5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,4 +3,4 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" + interval: "monthly" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 9a799d4..edbfe30 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -17,7 +17,7 @@ jobs: runs-on: "${{ matrix.os }}-latest" strategy: matrix: - os: [ubuntu, macos] + os: [ubuntu] steps: - uses: actions/checkout@v5 - uses: cachix/install-nix-action@v31 @@ -26,6 +26,7 @@ jobs: name: gepetto authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - run: nix flake check -L + - run: nix build -L check: if: always() name: check-macos-linux-nix diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml index bcf8b1d..4bf7180 100644 --- a/.github/workflows/update-flake-lock.yml +++ b/.github/workflows/update-flake-lock.yml @@ -6,14 +6,28 @@ on: - cron: '0 6 13 * *' jobs: - lockfile: - runs-on: ubuntu-latest + update-flake-inputs: + runs-on: ubuntu-slim + permissions: + contents: write + pull-requests: write steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.GEPETTO_NIX_APP_ID }} + private-key: ${{ secrets.GEPETTO_NIX_APP_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@main - - name: Update flake.lock - uses: DeterminateSystems/update-flake-lock@main + uses: actions/checkout@v6 + with: + token: ${{ steps.app-token.outputs.token }} + - name: Setup Nix + uses: cachix/install-nix-action@v31 + - name: Update flake inputs + uses: mic92/update-flake-inputs@v1 with: - token: ${{ secrets.GH_TOKEN_FOR_UPDATES }} + github-token: ${{ steps.app-token.outputs.token }} + pr-labels: 'no-changelog' + git-author-name: 'hrp2-14' + git-author-email: '40568249+hrp2-14@users.noreply.github.com' diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 2b7a4fe..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "cmake"] - path = cmake - url = https://github.com/jrl-umi3218/jrl-cmakemodules.git diff --git a/.mergify.yml b/.mergify.yml index 1e2dd5a..8ece7ea 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -5,6 +5,7 @@ pull_request_rules: - check-success = "pre-commit.ci - pr" - or: - author = dependabot[bot] + - author = gepetto-flake-updater[bot] - author = github-actions[bot] - author = hrp2-14 - author = pre-commit-ci[bot] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 416f304..3c0cdb0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,8 @@ ci: - autoupdate_branch: devel + autoupdate_schedule: quarterly repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.1 + rev: v0.15.5 hooks: - id: ruff args: @@ -19,7 +19,7 @@ repos: - id: toml-sort-fix exclude: poetry.lock - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v21.1.2 + rev: v22.1.0 hooks: - id: clang-format args: diff --git a/CMakeLists.txt b/CMakeLists.txt index 4543716..2c66705 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,8 @@ else() endif() include("${JRL_CMAKE_MODULES}/hpp.cmake") +include("${JRL_CMAKE_MODULES}/python.cmake") +include("${JRL_CMAKE_MODULES}/boost.cmake") # Tells pkg-config to read qtversion from pkg config file. list(APPEND PKG_CONFIG_ADDITIONAL_VARIABLES qtversion cmake_plugin) @@ -124,10 +126,17 @@ else(USE_QT4) endif(USE_QT4) add_project_dependency("hpp-manipulation-corba" REQUIRED) +add_project_dependency("hpp-manipulation" REQUIRED) add_project_dependency("qgv" REQUIRED) -set(${PROJECT_NAME}_HEADERS include/hpp/plot/graph-widget.hh - include/hpp/plot/hpp-manipulation-graph.hh) +# Python bindings for native graph viewer +set(PYTHON_COMPONENTS Interpreter Development NumPy) +findpython() +search_for_boost_python() + +set(${PROJECT_NAME}_HEADERS + include/hpp/plot/graph-widget.hh include/hpp/plot/hpp-manipulation-graph.hh + include/hpp/plot/hpp-native-graph.hh) set(${PROJECT_NAME}_HEADERS_NOMOC) set(${PROJECT_NAME}_FORMS) @@ -146,7 +155,8 @@ else(USE_QT4) endif(USE_QT4) add_definitions(${QT_DEFINITIONS}) -set(${PROJECT_NAME}_SOURCES src/graph-widget.cc src/hpp-manipulation-graph.cc) +set(${PROJECT_NAME}_SOURCES src/graph-widget.cc src/hpp-manipulation-graph.cc + src/hpp-native-graph.cc) add_library( ${PROJECT_NAME} SHARED @@ -157,7 +167,7 @@ add_library( target_link_libraries( ${PROJECT_NAME} PUBLIC ${QT_LIBRARIES} hpp-manipulation-corba::hpp-manipulation-corba - qgv::qgvcore) + hpp-manipulation::hpp-manipulation qgv::qgvcore) install( TARGETS ${PROJECT_NAME} @@ -166,3 +176,63 @@ install( add_subdirectory(bin) add_subdirectory(plugins) + +# Python module for native graph viewer +add_library(pyhpp_plot_graph_viewer MODULE src/pyhpp_plot/graph_viewer.cc) +target_link_boost_python(pyhpp_plot_graph_viewer PUBLIC) +target_link_libraries( + pyhpp_plot_graph_viewer + PUBLIC ${PROJECT_NAME} ${QT_LIBRARIES} hpp-manipulation::hpp-manipulation + ${PYTHON_LIBRARIES}) +target_include_directories(pyhpp_plot_graph_viewer + PRIVATE ${PYTHON_INCLUDE_DIRS}) +set_target_properties( + pyhpp_plot_graph_viewer + PROPERTIES PREFIX "" + OUTPUT_NAME "graph_viewer" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/pyhpp_plot") + +# pyhpp_plot_graph_viewer is a python module that spawn Qt windows. for this, at +# runtime, it need a QT_PLUGIN_PATH env var with a path where the shared lib for +# the current Qt "platform" are. Here we find a QT_PLUGIN_PATH that would work +# at config time, so that at runtime we can append that as a default fallback, +# if a end user has Qt installed in a non-FHS standard place and did not +# manually tune their $QT_PLUGIN_PATH env var for it + +set(QT_PLUGIN_PATH_CMAKE) + +# First, we might need to manually discover platforms plugins which are not +# installed in the same prefix as Qt5Gui. eg. Wayland. +find_package( + Qt5 CONFIG + COMPONENTS WaylandClient + QUIET) +if(Qt5WaylandClient_FOUND) + cmake_path(GET Qt5WaylandClient_DIR PARENT_PATH _p) + file(GLOB pluginTargets "${_p}/Qt5Gui/Qt5Gui_*.cmake") + if(pluginTargets) + foreach(pluginTarget ${pluginTargets}) + include(${pluginTarget}) + endforeach() + endif() +endif() + +foreach(p IN LISTS Qt5Gui_PLUGINS) + get_target_property(_l ${p} IMPORTED_LOCATION_RELEASE) + cmake_path(GET _l PARENT_PATH _pp) + cmake_path(GET _pp PARENT_PATH _ppp) + if(NOT _ppp IN_LIST QT_PLUGIN_PATH_CMAKE) + set(QT_PLUGIN_PATH_CMAKE ${QT_PLUGIN_PATH_CMAKE} ${_ppp}) + endif() +endforeach() + +add_compile_definitions( + "QT_PLUGIN_PATH_CMAKE=\"$\"") + +install(TARGETS pyhpp_plot_graph_viewer + DESTINATION "${PYTHON_SITELIB}/pyhpp_plot") + +# Install Python modules +install(FILES "${CMAKE_SOURCE_DIR}/src/pyhpp_plot/__init__.py" + "${CMAKE_SOURCE_DIR}/src/pyhpp_plot/interactive_viewer.py" + DESTINATION "${PYTHON_SITELIB}/pyhpp_plot") diff --git a/cmake b/cmake deleted file mode 160000 index c815b3b..0000000 --- a/cmake +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c815b3ba45fa8b7cf4b7a21b69b26ad0bc04fd5c diff --git a/flake.lock b/flake.lock index 851d4ba..a1c93df 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1760813311, - "narHash": "sha256-lbHQ7FXGzt6/IygWvJ1lCq+Txcut3xYYd6VIpF1ojkg=", + "lastModified": 1769996383, + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "4e627ac2e1b8f1de7f5090064242de9a259dbbc8", + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", "type": "github" }, "original": { @@ -36,37 +36,82 @@ "type": "github" } }, - "gepetto": { + "gazebros2nix": { "inputs": { "flake-parts": "flake-parts", + "gepetto-lib": "gepetto-lib", "nix-ros-overlay": "nix-ros-overlay", - "nix-system-graphics": "nix-system-graphics", "nixpkgs": [ "gepetto", + "gazebros2nix", + "nix-ros-overlay", + "nixpkgs" + ], + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "systems": [ + "gepetto", + "gazebros2nix", "nix-ros-overlay", + "flake-utils", + "systems" + ], + "treefmt-nix": "treefmt-nix", + "uv2nix": "uv2nix" + }, + "locked": { + "lastModified": 1772816781, + "narHash": "sha256-Ac0KEl+8ygy+BnDgczNHgTumw8HpCasp/zJU5Yx3kQs=", + "owner": "gepetto", + "repo": "gazebros2nix", + "rev": "ea8aff2fca6d45fa85fe5e90ef3c71fe0fcc0d12", + "type": "github" + }, + "original": { + "owner": "gepetto", + "repo": "gazebros2nix", + "type": "github" + } + }, + "gepetto": { + "inputs": { + "flake-parts": [ + "gepetto", + "gazebros2nix", + "flake-parts" + ], + "gazebros2nix": "gazebros2nix", + "nix-ros-overlay": [ + "gepetto", + "gazebros2nix", + "nix-ros-overlay" + ], + "nix-system-graphics": "nix-system-graphics", + "nixpkgs": [ + "gepetto", + "gazebros2nix", "nixpkgs" ], - "src-agimus-controller": "src-agimus-controller", - "src-agimus-msgs": "src-agimus-msgs", - "src-franka-description": "src-franka-description", - "src-gepetto-viewer": "src-gepetto-viewer", "src-odri-control-interface": "src-odri-control-interface", "src-odri-masterboard-sdk": "src-odri-masterboard-sdk", "system-manager": "system-manager", "systems": [ "gepetto", - "nix-ros-overlay", - "flake-utils", + "gazebros2nix", "systems" ], - "treefmt-nix": "treefmt-nix" + "treefmt-nix": [ + "gepetto", + "gazebros2nix", + "treefmt-nix" + ] }, "locked": { - "lastModified": 1760912477, - "narHash": "sha256-f13hr5lblgh/1L902D5inme4jrxIUa5CNMoJrJ5CAbY=", + "lastModified": 1772825496, + "narHash": "sha256-ZCgGWufV1suEVlft03k9TGOD190kGRCA3rrO8qsjeQ0=", "owner": "gepetto", "repo": "nix", - "rev": "5b37aa4185cd3797bd024b88b3a4b6c78a50a032", + "rev": "5c1a5edffd02c51e267c42f8dfd36a13c7817950", "type": "github" }, "original": { @@ -75,17 +120,32 @@ "type": "github" } }, + "gepetto-lib": { + "locked": { + "lastModified": 1770945346, + "narHash": "sha256-L88f+oJbpIkMm9Ln1GP9SFyGztMvnOowbdshQHBeGGs=", + "owner": "Gepetto", + "repo": "nix-lib", + "rev": "82ef58cdf50514f6b1fde96b9d5b38fd8d3e83f5", + "type": "github" + }, + "original": { + "owner": "Gepetto", + "repo": "nix-lib", + "type": "github" + } + }, "nix-ros-overlay": { "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1760853526, - "narHash": "sha256-oIRhuCXd/uDXcYyb/MZC0uVfMU/PM0OcAda47FfK32s=", + "lastModified": 1771885942, + "narHash": "sha256-TlBFvE4YHNlbhKVkayP/FWBNAAv+yG9APA8vMR+5NBw=", "owner": "lopsided98", "repo": "nix-ros-overlay", - "rev": "e34da327d2988480fc91fa42b703e8c2149ca972", + "rev": "f891b118c8f4ddb2b6f38d6ce1bfe2f8079552ba", "type": "github" }, "original": { @@ -103,11 +163,11 @@ ] }, "locked": { - "lastModified": 1737457219, - "narHash": "sha256-nX9dxoATDCSQgWw/iv6BngXDJEyHVYYEvHEVQ7Ig3fI=", + "lastModified": 1763296639, + "narHash": "sha256-K9JBscC7ApwCnl0wR0sVkxrKFsoDYVqXN5fOujvyBWA=", "owner": "soupglasses", "repo": "nix-system-graphics", - "rev": "9c875e0c56cf2eb272b9102a4f3e24e4e31629fd", + "rev": "ac37f0f3ec0cb15d63a520918433c794d01d9dac", "type": "github" }, "original": { @@ -134,11 +194,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1754788789, - "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "lastModified": 1769909678, + "narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "rev": "72716169fe93074c333e8d0173151350670b824c", "type": "github" }, "original": { @@ -147,94 +207,87 @@ "type": "github" } }, - "root": { + "pyproject-build-systems": { "inputs": { - "flake-parts": [ - "gepetto", - "flake-parts" - ], - "gepetto": "gepetto", - "nix-ros-overlay": [ - "gepetto", - "nix-ros-overlay" - ], "nixpkgs": [ "gepetto", + "gazebros2nix", "nixpkgs" ], - "systems": [ + "pyproject-nix": [ "gepetto", - "systems" + "gazebros2nix", + "pyproject-nix" ], - "treefmt-nix": [ + "uv2nix": [ "gepetto", - "treefmt-nix" + "gazebros2nix", + "uv2nix" ] - } - }, - "src-agimus-controller": { - "flake": false, - "locked": { - "lastModified": 1760697732, - "narHash": "sha256-7qnvv4VgLcpSFzsvKsbHJBsM+FZE1yhzYZknhVg7FuY=", - "owner": "agimus-project", - "repo": "agimus_controller", - "rev": "f37cd913920d81613ebbc0c75937705ce1f6c280", - "type": "github" }, - "original": { - "owner": "agimus-project", - "repo": "agimus_controller", - "type": "github" - } - }, - "src-agimus-msgs": { - "flake": false, "locked": { - "lastModified": 1759994370, - "narHash": "sha256-QuYtUR7VTOOtRBrYCxDjSLUP77wh0NlHbMtxZ1nSJFM=", - "owner": "agimus-project", - "repo": "agimus_msgs", - "rev": "e8e48c8c7b942cc2d2feba01f5e2d319c7915816", + "lastModified": 1771423342, + "narHash": "sha256-7uXPiWB0YQ4HNaAqRvVndYL34FEp1ZTwVQHgZmyMtC8=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "04e9c186e01f0830dad3739088070e4c551191a4", "type": "github" }, "original": { - "owner": "agimus-project", - "repo": "agimus_msgs", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", "type": "github" } }, - "src-franka-description": { - "flake": false, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "gepetto", + "gazebros2nix", + "nixpkgs" + ] + }, "locked": { - "lastModified": 1759137774, - "narHash": "sha256-D7vkjZ1B9qKecqUCmnpwHcxzZpakvoqp0qAhv4jGwRI=", - "owner": "agimus-project", - "repo": "franka_description", - "rev": "3cad53b368ea19f10ce1248f4fd781abfae1bdc6", + "lastModified": 1771518446, + "narHash": "sha256-nFJSfD89vWTu92KyuJWDoTQJuoDuddkJV3TlOl1cOic=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "eb204c6b3335698dec6c7fc1da0ebc3c6df05937", "type": "github" }, "original": { - "owner": "agimus-project", - "repo": "franka_description", + "owner": "pyproject-nix", + "repo": "pyproject.nix", "type": "github" } }, - "src-gepetto-viewer": { - "flake": false, - "locked": { - "lastModified": 1760391797, - "narHash": "sha256-5aWplS40CWL1GvF4LuLtipRP4TkTLNnOJUVTwzixpHA=", - "owner": "Gepetto", - "repo": "gepetto-viewer", - "rev": "1c942ecf7c755f615dcd43e17101a1f680795b8b", - "type": "github" - }, - "original": { - "owner": "Gepetto", - "ref": "devel", - "repo": "gepetto-viewer", - "type": "github" + "root": { + "inputs": { + "flake-parts": [ + "gepetto", + "flake-parts" + ], + "gazebros2nix": [ + "gepetto", + "gazebros2nix" + ], + "gepetto": "gepetto", + "nix-ros-overlay": [ + "gepetto", + "nix-ros-overlay" + ], + "nixpkgs": [ + "gepetto", + "nixpkgs" + ], + "systems": [ + "gepetto", + "systems" + ], + "treefmt-nix": [ + "gepetto", + "treefmt-nix" + ] } }, "src-odri-control-interface": { @@ -279,11 +332,11 @@ ] }, "locked": { - "lastModified": 1756281415, - "narHash": "sha256-CjpoVwpJJ+DOZilPrDpZ5S3V+B1Y0calaHxTp2xMvGs=", + "lastModified": 1770135975, + "narHash": "sha256-J3qmZ4rTfmgyjrsQRrQWT7ZIYVtYqtLomMNDUibuw2k=", "owner": "numtide", "repo": "system-manager", - "rev": "e271eedac9a24678ca6cfc61677837422bf474e0", + "rev": "413f296fb1fd210c44e38744e270b3afc4c733d7", "type": "github" }, "original": { @@ -311,15 +364,16 @@ "inputs": { "nixpkgs": [ "gepetto", + "gazebros2nix", "nixpkgs" ] }, "locked": { - "lastModified": 1760802554, - "narHash": "sha256-5YkOYOCF8/XNw89/ABKFB0c/P78U2EVuKRDGTql6+kA=", + "lastModified": 1770228511, + "narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "296ebf0c3668ebceb3b0bfee55298f112b4b5754", + "rev": "337a4fe074be1042a35086f15481d763b8ddc0e7", "type": "github" }, "original": { @@ -327,6 +381,33 @@ "repo": "treefmt-nix", "type": "github" } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "gepetto", + "gazebros2nix", + "nixpkgs" + ], + "pyproject-nix": [ + "gepetto", + "gazebros2nix", + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1772187362, + "narHash": "sha256-gCojeIlQ/rfWMe3adif3akyHsT95wiMkLURpxTeqmPc=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "abe65de114300de41614002fe9dce2152ac2ac23", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 928b744..2f10777 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,7 @@ inputs = { gepetto.url = "github:gepetto/nix"; + gazebros2nix.follows = "gepetto/gazebros2nix"; flake-parts.follows = "gepetto/flake-parts"; nixpkgs.follows = "gepetto/nixpkgs"; nix-ros-overlay.follows = "gepetto/nix-ros-overlay"; @@ -12,20 +13,14 @@ outputs = inputs: - inputs.flake-parts.lib.mkFlake { inherit inputs; } { - systems = import inputs.systems; - imports = [ inputs.gepetto.flakeModule ]; - perSystem = - { - lib, - pkgs, - self', - ... - }: - { - packages = { - default = self'.packages.hpp-plot; - hpp-plot = pkgs.python3Packages.hpp-plot.overrideAttrs { + inputs.flake-parts.lib.mkFlake { inherit inputs; } ( + { lib, ... }: + { + systems = import inputs.systems; + imports = [ + inputs.gepetto.flakeModule + { + gazebros2nix.overrides.hpp-plot = _final: { src = lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions [ @@ -40,7 +35,8 @@ ]; }; }; - }; - }; - }; + } + ]; + } + ); } diff --git a/include/hpp/plot/graph-widget.hh b/include/hpp/plot/graph-widget.hh index 2f3a776..c2b5ef8 100644 --- a/include/hpp/plot/graph-widget.hh +++ b/include/hpp/plot/graph-widget.hh @@ -59,12 +59,12 @@ class GraphWidget : public QWidget { ~GraphWidget(); - public slots: + public Q_SLOTS: void updateGraph(); void updateEdges(); void saveDotFile(); - protected slots: + protected Q_SLOTS: virtual void nodeContextMenu(QGVNode* node); virtual void nodeDoubleClick(QGVNode* node); virtual void edgeContextMenu(QGVEdge* edge); diff --git a/include/hpp/plot/hpp-manipulation-graph.hh b/include/hpp/plot/hpp-manipulation-graph.hh index 8ef125e..009d7eb 100644 --- a/include/hpp/plot/hpp-manipulation-graph.hh +++ b/include/hpp/plot/hpp-manipulation-graph.hh @@ -52,10 +52,10 @@ class GraphAction : public QAction { public: GraphAction(HppManipulationGraphWidget* parent); - signals: + Q_SIGNALS: void activated(hpp::ID id); - private slots: + private Q_SLOTS: void transferSignal(); private: @@ -83,14 +83,14 @@ class HppManipulationGraphWidget : public GraphWidget { protected: void fillScene(); - public slots: + public Q_SLOTS: void updateStatistics(); void showNodeOfConfiguration(const hpp::floatSeq& cfg); void displayNodeConstraint(hpp::ID id); void displayEdgeConstraint(hpp::ID id); void displayEdgeTargetConstraint(hpp::ID id); - protected slots: + protected Q_SLOTS: virtual void nodeContextMenu(QGVNode* node); virtual void nodeDoubleClick(QGVNode* node); virtual void edgeContextMenu(QGVEdge* edge); @@ -98,7 +98,7 @@ class HppManipulationGraphWidget : public GraphWidget { void selectionChanged(); - private slots: + private Q_SLOTS: void startStopUpdateStats(bool start); private: diff --git a/include/hpp/plot/hpp-native-graph.hh b/include/hpp/plot/hpp-native-graph.hh new file mode 100644 index 0000000..121b2f4 --- /dev/null +++ b/include/hpp/plot/hpp-native-graph.hh @@ -0,0 +1,188 @@ +// BSD 2-Clause License + +// Copyright (c) 2025, hpp-plot +// Authors: Paul Sardin +// All rights reserved. + +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: + +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. + +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in +// the documentation and/or other materials provided with the +// distribution. + +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +// OF THE POSSIBILITY OF SUCH DAMAGE. + +#ifndef HPP_PLOT_HPP_NATIVE_GRAPH_HH +#define HPP_PLOT_HPP_NATIVE_GRAPH_HH + +#include +#include +#include +#include + +namespace hpp { +namespace plot { + +/// \brief Constraint graph viewer using native C++ API +/// +/// This widget displays constraint graphs from hpp-manipulation without +/// requiring CORBA. It reads graph structure directly from the C++ Graph +/// object using the same visualization style as HppManipulationGraphWidget. +class HppNativeGraphWidget : public GraphWidget { + Q_OBJECT + + public: + /// \brief Constructor + /// \param graph Shared pointer to manipulation graph (can be nullptr) + /// \param parent Parent widget + HppNativeGraphWidget(hpp::manipulation::graph::GraphPtr_t graph = nullptr, + QWidget* parent = nullptr); + + /// \brief Destructor + ~HppNativeGraphWidget(); + + /// \brief Set graph object and refresh display + /// \param graph Shared pointer to manipulation graph + void setGraph(hpp::manipulation::graph::GraphPtr_t graph); + + /// \brief Get the graph name + const std::string& graphName() const { return graphName_; } + + /// \brief Get currently selected element ID + /// \param[out] id The ID of the selected element + /// \return true if an element is selected, false otherwise + bool selectionID(std::size_t& id); + + /// \brief Highlight a specific node (e.g., for current configuration) + /// \param nodeId The ID of the node to highlight, or -1 to clear + void highlightNode(long nodeId); + + /// \brief Highlight a specific edge + /// \param edgeId The ID of the edge to highlight, or -1 to clear + void highlightEdge(long edgeId); + + public Q_SLOTS: + /// \brief Display detailed state constraints in the constraint panel + void displayStateConstraints(std::size_t id); + + /// \brief Display detailed edge constraints in the constraint panel + void displayEdgeConstraints(std::size_t id); + + /// \brief Display edge target constraints in the constraint panel + void displayEdgeTargetConstraints(std::size_t id); + + Q_SIGNALS: + /// \brief Emitted before showing node context menu, allows external code to + /// add actions + /// \param nodeId The ID of the node + /// \param nodeName The name of the node (state) + /// \param menu Pointer to the context menu (can add actions before it's + /// shown) + void nodeContextMenuAboutToShow(std::size_t nodeId, QString nodeName, + QMenu* menu); + + /// \brief Emitted before showing edge context menu, allows external code to + /// add actions + /// \param edgeId The ID of the edge + /// \param edgeName The name of the edge (transition) + /// \param menu Pointer to the context menu (can add actions before it's + /// shown) + void edgeContextMenuAboutToShow(std::size_t edgeId, QString edgeName, + QMenu* menu); + + protected: + /// \brief Fill scene from Graph object + /// Reads nodes and edges directly from the C++ graph structure + void fillScene() override; + + protected Q_SLOTS: + void nodeContextMenu(QGVNode* node) override; + void nodeDoubleClick(QGVNode* node) override; + void edgeContextMenu(QGVEdge* edge) override; + void edgeDoubleClick(QGVEdge* edge) override; + void selectionChanged(); + + private: + /// Get constraint names for a graph component + QString getConstraints( + const hpp::manipulation::graph::GraphComponentPtr_t& component); + + /// Get detailed constraint string (with solver info) for a state + QString getDetailedStateConstraints(std::size_t stateId); + + /// Get detailed constraint string for an edge + QString getDetailedEdgeConstraints(std::size_t edgeId); + + /// Get detailed constraint string for edge target + QString getDetailedEdgeTargetConstraints(std::size_t edgeId); + + /// Update edge visual style based on weight + void updateEdgeStyle(QGVEdge* edge, long weight); + + hpp::manipulation::graph::GraphPtr_t graph_; + std::string graphName_; + + /// Graph-level information + struct GraphInfo { + std::size_t id; + QString constraintStr; + } graphInfo_; + + /// Node information + struct NodeInfo { + std::size_t id; + QString name; + QString constraintStr; + QGVNode* node; + bool isWaypoint; + }; + + /// Edge information + struct EdgeInfo { + std::size_t id; + QString name; + QString constraintStr; + QString containingStateName; + long weight; + bool isShort; + std::size_t nbWaypoints; // Number of waypoints (0 for regular edges) + QGVEdge* edge; + }; + + QMap nodeInfos_; + QMap edgeInfos_; + QMap nodes_; + QMap edges_; + + /// UI buttons + QPushButton* showWaypoints_; + + /// Currently selected element ID (-1 if none) + long currentId_; + + /// Currently highlighted node/edge IDs (-1 if none) + long highlightedNodeId_; + long highlightedEdgeId_; +}; + +} // namespace plot +} // namespace hpp + +#endif // HPP_PLOT_HPP_NATIVE_GRAPH_HH diff --git a/src/graph-widget.cc b/src/graph-widget.cc index fcfd846..75cfe70 100644 --- a/src/graph-widget.cc +++ b/src/graph-widget.cc @@ -149,6 +149,8 @@ GraphWidget::GraphWidget(QString name, QWidget* parent) connect(saveas, SIGNAL(clicked()), this, SLOT(saveDotFile())); connect(refresh, SIGNAL(clicked()), this, SLOT(updateGraph())); connect(update, SIGNAL(clicked()), this, SLOT(updateEdges())); + connect(algList_, SIGNAL(currentIndexChanged(int)), this, + SLOT(updateGraph())); connect(scene_, SIGNAL(nodeMouseRelease(QGVNode*)), this, SLOT(updateEdges())); @@ -176,7 +178,7 @@ void GraphWidget::updateGraph() { // scene_->render("canon", "debug.dot"); // Fit in view - // view_->fitInView(scene_->sceneRect(), Qt::KeepAspectRatio); + view_->fitInView(scene_->sceneRect(), Qt::KeepAspectRatio); } void GraphWidget::updateEdges() { diff --git a/src/hpp-native-graph.cc b/src/hpp-native-graph.cc new file mode 100644 index 0000000..bfce05b --- /dev/null +++ b/src/hpp-native-graph.cc @@ -0,0 +1,719 @@ +// BSD 2-Clause License + +// Copyright (c) 2025, hpp-plot +// Authors: Paul Sardin +// All rights reserved. + +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: + +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. + +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in +// the documentation and/or other materials provided with the +// distribution. + +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +// OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "hpp/plot/hpp-native-graph.hh" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if (QT_VERSION < QT_VERSION_CHECK(5, 0, 0)) +#define ESCAPE(q) Qt::escape(q) +#else +#define ESCAPE(q) q.toHtmlEscaped() +#endif + +namespace hpp { +namespace plot { + +using manipulation::graph::Edge; +using manipulation::graph::EdgePtr_t; +using manipulation::graph::Edges_t; +using manipulation::graph::GraphComponentPtr_t; +using manipulation::graph::GraphPtr_t; +using manipulation::graph::State; +using manipulation::graph::StatePtr_t; +using manipulation::graph::States_t; +using manipulation::graph::StateSelectorPtr_t; +using manipulation::graph::WaypointEdge; +using manipulation::graph::WaypointEdgePtr_t; + +HppNativeGraphWidget::HppNativeGraphWidget(GraphPtr_t graph, QWidget* parent) + : GraphWidget("Manipulation graph", parent), + graph_(graph), + showWaypoints_(new QPushButton(QIcon::fromTheme("view-refresh"), + "&Show waypoints", buttonBox_)), + currentId_(-1), + highlightedNodeId_(-1), + highlightedEdgeId_(-1) { + graphInfo_.id = 0; + + if (graph_) { + graphName_ = graph_->name(); + graphInfo_.id = graph_->id(); + graphInfo_.constraintStr = getConstraints(graph_); + } + + // Setup Show Waypoints button + showWaypoints_->setCheckable(true); + showWaypoints_->setChecked(false); + buttonBox_->layout()->addWidget(showWaypoints_); + + // Connect signals + connect(scene_, SIGNAL(selectionChanged()), this, SLOT(selectionChanged())); + connect(showWaypoints_, SIGNAL(clicked()), this, SLOT(updateGraph())); +} + +HppNativeGraphWidget::~HppNativeGraphWidget() {} + +void HppNativeGraphWidget::setGraph(GraphPtr_t graph) { + graph_ = graph; + if (graph_) { + graphName_ = graph_->name(); + graphInfo_.id = graph_->id(); + graphInfo_.constraintStr = getConstraints(graph_); + } else { + graphName_.clear(); + graphInfo_.id = 0; + graphInfo_.constraintStr.clear(); + } + updateGraph(); +} + +bool HppNativeGraphWidget::selectionID(std::size_t& id) { + if (currentId_ < 0) return false; + id = static_cast(currentId_); + return true; +} + +void HppNativeGraphWidget::highlightNode(long nodeId) { + // Clear previous highlight + if (highlightedNodeId_ >= 0 && + nodes_.contains(static_cast(highlightedNodeId_))) { + QGVNode* prevNode = nodes_[static_cast(highlightedNodeId_)]; + prevNode->setAttribute("fillcolor", "white"); + prevNode->updateLayout(); + } + + highlightedNodeId_ = nodeId; + + // Apply new highlight + if (nodeId >= 0 && nodes_.contains(static_cast(nodeId))) { + QGVNode* node = nodes_[static_cast(nodeId)]; + node->setAttribute("fillcolor", "green"); + node->updateLayout(); + scene_->update(); + } +} + +void HppNativeGraphWidget::highlightEdge(long edgeId) { + // Clear previous highlight + if (highlightedEdgeId_ >= 0 && + edges_.contains(static_cast(highlightedEdgeId_))) { + QGVEdge* prevEdge = edges_[static_cast(highlightedEdgeId_)]; + prevEdge->setAttribute("color", ""); + prevEdge->updateLayout(); + } + + highlightedEdgeId_ = edgeId; + + // Apply new highlight + if (edgeId >= 0 && edges_.contains(static_cast(edgeId))) { + QGVEdge* edge = edges_[static_cast(edgeId)]; + edge->setAttribute("color", "green"); + edge->updateLayout(); + scene_->update(); + } +} + +void HppNativeGraphWidget::displayStateConstraints(std::size_t id) { + QString str = getDetailedStateConstraints(id); + constraintInfo_->setText(str); +} + +void HppNativeGraphWidget::displayEdgeConstraints(std::size_t id) { + QString str = getDetailedEdgeConstraints(id); + constraintInfo_->setText(str); +} + +void HppNativeGraphWidget::displayEdgeTargetConstraints(std::size_t id) { + QString str = getDetailedEdgeTargetConstraints(id); + constraintInfo_->setText(str); +} + +void HppNativeGraphWidget::fillScene() { + if (!graph_) { + qDebug() << "No graph set"; + return; + } + + try { + // Set graph attributes + scene_->setGraphAttribute("label", QString::fromStdString(graph_->name())); + scene_->setGraphAttribute("splines", "spline"); + scene_->setGraphAttribute("outputorder", "edgesfirst"); + scene_->setGraphAttribute("nodesep", "0.5"); + scene_->setGraphAttribute("esep", "0.8"); + scene_->setGraphAttribute("sep", "1"); + + // Node attributes + scene_->setNodeAttribute("shape", "circle"); + scene_->setNodeAttribute("style", "filled"); + scene_->setNodeAttribute("fillcolor", "white"); + + // Clear existing maps + nodeInfos_.clear(); + edgeInfos_.clear(); + nodes_.clear(); + edges_.clear(); + + // Get state selector + StateSelectorPtr_t selector = graph_->stateSelector(); + if (!selector) { + qDebug() << "No state selector"; + return; + } + + // Get all states + States_t states = selector->getStates(); + bool hideWaypoints = !showWaypoints_->isChecked(); + + QMap nodeIsWaypoint; + QMap waypointStates; + + // Initialize all states as non-waypoints + for (const StatePtr_t& state : states) { + if (!state) continue; + nodeIsWaypoint[state->id()] = false; + } + + // Find WaypointEdges and mark their internal states as waypoints + for (const StatePtr_t& state : states) { + if (!state) continue; + for (const EdgePtr_t& edge : state->neighborEdges()) { + if (!edge) continue; + WaypointEdgePtr_t waypointEdge = + std::dynamic_pointer_cast(edge); + if (waypointEdge && waypointEdge->nbWaypoints() > 0) { + // Get waypoint states from internal edges + for (std::size_t i = 0; i <= waypointEdge->nbWaypoints(); ++i) { + EdgePtr_t innerEdge = waypointEdge->waypoint(i); + if (innerEdge) { + StatePtr_t toState = innerEdge->stateTo(); + if (toState && i < waypointEdge->nbWaypoints()) { + nodeIsWaypoint[toState->id()] = true; + waypointStates[toState->id()] = toState; + } + } + } + } + } + } + + // Helper lambda to add a state node to the scene + auto addStateNode = [&](const StatePtr_t& state, bool isWaypoint, + bool isFirst) { + QString nodeName = QString::fromStdString(state->name()); + nodeName.replace(" : ", "\n"); + + QGVNode* n = scene_->addNode(nodeName); + if (isFirst) { + scene_->setRootNode(n); + } + + NodeInfo ni; + ni.id = state->id(); + ni.name = QString::fromStdString(state->name()); + ni.node = n; + ni.isWaypoint = isWaypoint; + ni.constraintStr = getConstraints(state); + + nodeInfos_[n] = ni; + nodes_[ni.id] = n; + + n->setFlag(QGraphicsItem::ItemIsMovable, true); + n->setFlag(QGraphicsItem::ItemSendsGeometryChanges, true); + + // Mark waypoint nodes with different shape + if (isWaypoint) { + n->setAttribute("shape", "hexagon"); + } + }; + + // Add regular states (always visible) + bool first = true; + for (const StatePtr_t& state : states) { + if (!state) continue; + + // Skip waypoint states if hiding waypoints + if (hideWaypoints && nodeIsWaypoint.value(state->id(), false)) { + continue; + } + + addStateNode(state, nodeIsWaypoint.value(state->id(), false), first); + first = false; + } + + // Add waypoint states if showing waypoints + if (!hideWaypoints) { + for (auto it = waypointStates.begin(); it != waypointStates.end(); ++it) { + if (!nodes_.contains(it.key())) { + addStateNode(it.value(), true, first); + first = false; + } + } + } + + // Helper lambda to add an edge to the scene + auto addEdgeToScene = [&](const EdgePtr_t& edge, long weight, + std::size_t nbWaypoints) { + if (!edge) return; + if (edges_[edge->id()] != nullptr) return; + StatePtr_t fromState = edge->stateFrom(); + StatePtr_t toState = edge->stateTo(); + StatePtr_t containingState = edge->state(); + + if (!fromState || !toState) return; + + if (!nodes_.contains(fromState->id()) || !nodes_.contains(toState->id())) + return; + + QGVNode* fromNode = nodes_[fromState->id()]; + QGVNode* toNode = nodes_[toState->id()]; + + QGVEdge* e = scene_->addEdge(fromNode, toNode, ""); + + EdgeInfo ei; + ei.id = edge->id(); + ei.name = QString::fromStdString(edge->name()); + ei.edge = e; + ei.isShort = edge->isShort(); + ei.weight = weight; + ei.nbWaypoints = nbWaypoints; + + if (containingState) { + ei.containingStateName = + QString::fromStdString(containingState->name()); + } + + // Build constraint string + if (nbWaypoints > 0 && hideWaypoints) { + ei.constraintStr = + tr("

Waypoint transition

" + "This transition has %1 waypoints.
" + "To see the constraints of the transitions inside,
" + "re-draw the graph after enabling \"Show waypoints\"

") + .arg(nbWaypoints); + } else { + ei.constraintStr = getConstraints(edge); + } + + // Update edge style + updateEdgeStyle(e, weight); + + // For transitions inside waypoint edges, adjust layout + if (weight < 0) { + e->setAttribute("weight", "3"); + if (fromState->id() >= toState->id()) { + e->setAttribute("constraint", "false"); + } + } + + edgeInfos_[e] = ei; + edges_[ei.id] = e; + }; + + // Add edges from each state + for (const StatePtr_t& state : states) { + if (!state) continue; + + // Get all outgoing edges from this state + Edges_t neighborEdges = state->neighborEdges(); + + for (const EdgePtr_t& edge : neighborEdges) { + if (!edge) continue; + + // Get weight from the source state + long weight = static_cast(state->getWeight(edge)); + + // Check if this is a waypoint edge + WaypointEdgePtr_t waypointEdge = + std::dynamic_pointer_cast(edge); + std::size_t nbWaypoints = + waypointEdge ? waypointEdge->nbWaypoints() : 0; + + // Determine edge visibility based on waypoint settings + bool hasWaypoints = nbWaypoints > 0; + bool edgeVisible = + (!hideWaypoints && !hasWaypoints) || (hideWaypoints && weight >= 0); + + if (edgeVisible) { + addEdgeToScene(edge, weight, nbWaypoints); + } else if (!hideWaypoints && hasWaypoints) { + for (std::size_t i = 0; i <= nbWaypoints; ++i) { + EdgePtr_t innerEdge = waypointEdge->waypoint(i); + if (innerEdge) { + // Inner edges have negative weight (from the containing state) + long innerWeight = -1; + addEdgeToScene(innerEdge, innerWeight, 0); + } + } + } + } + } + + qDebug() << "Added" << nodes_.size() << "nodes and" << edges_.size() + << "edges"; + + } catch (const std::exception& e) { + qDebug() << "Error filling scene:" << e.what(); + } +} + +void HppNativeGraphWidget::updateEdgeStyle(QGVEdge* edge, long weight) { + if (!edge) return; + + if (weight <= 0) { + edge->setAttribute("style", "dashed"); + edge->setAttribute("penwidth", "1"); + } else { + edge->setAttribute("style", "filled"); + edge->setAttribute("penwidth", QString::number(1 + (weight - 1) / 5)); + } +} + +void HppNativeGraphWidget::nodeContextMenu(QGVNode* node) { + if (!nodeInfos_.contains(node)) return; + + const NodeInfo& ni = nodeInfos_[node]; + long savedId = currentId_; + currentId_ = static_cast(ni.id); + + QMenu cm("Node context menu", this); + + cm.addAction("Show constraints", + [this, &ni]() { constraintInfo_->setText(ni.constraintStr); }); + + cm.addAction("Show detailed constraints", + [this, &ni]() { displayStateConstraints(ni.id); }); + + cm.addSeparator(); + + cm.addAction("Highlight this state", + [this, &ni]() { highlightNode(static_cast(ni.id)); }); + + cm.addAction("Clear highlight", [this]() { highlightNode(-1); }); + + // Allow external code (Python) to add custom actions + Q_EMIT nodeContextMenuAboutToShow(ni.id, ni.name, &cm); + + cm.exec(QCursor::pos()); + + currentId_ = savedId; +} + +void HppNativeGraphWidget::nodeDoubleClick(QGVNode* node) { + if (!nodeInfos_.contains(node)) return; + + const NodeInfo& ni = nodeInfos_[node]; + + // Display detailed constraints on double-click + displayStateConstraints(ni.id); +} + +void HppNativeGraphWidget::edgeContextMenu(QGVEdge* edge) { + if (!edgeInfos_.contains(edge)) return; + + EdgeInfo& ei = edgeInfos_[edge]; + long savedId = currentId_; + currentId_ = static_cast(ei.id); + + QMenu cm("Edge context menu", this); + + cm.addAction("Show constraints", + [this, &ei]() { constraintInfo_->setText(ei.constraintStr); }); + + cm.addAction("Show detailed constraints", + [this, &ei]() { displayEdgeConstraints(ei.id); }); + + cm.addAction("Show target constraints", + [this, &ei]() { displayEdgeTargetConstraints(ei.id); }); + + cm.addSeparator(); + + cm.addAction(QString("Weight: %1").arg(ei.weight), []() { + // Weight display only - modification requires ProblemSolver access + })->setEnabled(false); + + cm.addSeparator(); + + cm.addAction("Highlight this edge", + [this, &ei]() { highlightEdge(static_cast(ei.id)); }); + + cm.addAction("Clear highlight", [this]() { highlightEdge(-1); }); + + // Allow external code (Python) to add custom actions + Q_EMIT edgeContextMenuAboutToShow(ei.id, ei.name, &cm); + + cm.exec(QCursor::pos()); + + currentId_ = savedId; +} + +void HppNativeGraphWidget::edgeDoubleClick(QGVEdge* edge) { + if (!edgeInfos_.contains(edge)) return; + + const EdgeInfo& ei = edgeInfos_[edge]; + + // Display detailed constraints on double-click + displayEdgeConstraints(ei.id); +} + +QString HppNativeGraphWidget::getConstraints( + const GraphComponentPtr_t& component) { + if (!component) return QString(); + + QString ret; + const auto& constraints = component->numericalConstraints(); + + ret.append("

Applied constraints

"); + if (!constraints.empty()) { + ret.append("
    "); + for (const auto& c : constraints) { + if (c) { + ret.append(QString("
  • %1
  • ") + .arg(QString::fromStdString(c->function().name()))); + } + } + ret.append("

"); + } else { + ret.append("No constraints applied

"); + } + + return ret; +} + +QString HppNativeGraphWidget::getDetailedStateConstraints(std::size_t stateId) { + if (!graph_) return QString(); + + // Find the state by ID + auto selector = graph_->stateSelector(); + if (!selector) return QString(); + + StatePtr_t targetState; + for (const auto& state : selector->getStates()) { + if (state && state->id() == stateId) { + targetState = state; + break; + } + } + + if (!targetState) return QString("

State not found

"); + + std::ostringstream oss; + oss << "

State: " << targetState->name() << "

\n"; + + // Get the config projector + auto configProjector = targetState->configConstraint(); + if (configProjector) { + oss << "
Configuration Constraints:
\n"; + oss << "
" << *configProjector << "
\n"; + } + + // Path constraints + auto pathConstraints = targetState->numericalConstraintsForPath(); + if (!pathConstraints.empty()) { + oss << "
Path Constraints:
\n
    "; + for (const auto& c : pathConstraints) { + if (c) { + oss << "
  • " << c->function().name() << "
  • \n"; + } + } + oss << "
\n"; + } + + return QString::fromStdString(oss.str()); +} + +QString HppNativeGraphWidget::getDetailedEdgeConstraints(std::size_t edgeId) { + if (!graph_) return QString(); + + // Find the edge by ID + auto selector = graph_->stateSelector(); + if (!selector) return QString(); + + EdgePtr_t targetEdge; + for (const auto& state : selector->getStates()) { + for (const auto& edge : state->neighborEdges()) { + if (edge && edge->id() == edgeId) { + targetEdge = edge; + break; + } + } + if (targetEdge) break; + } + + if (!targetEdge) return QString("

Edge not found

"); + + std::ostringstream oss; + oss << "

Edge: " << targetEdge->name() << "

\n"; + + // Path constraints (for paths belonging to this edge) + auto pathConstraint = targetEdge->pathConstraint(); + if (pathConstraint) { + oss << "
Path Constraints:
\n"; + oss << "
" << *pathConstraint << "
\n"; + } + + // List numerical constraints from GraphComponent base class + const auto& numConstraints = targetEdge->numericalConstraints(); + if (!numConstraints.empty()) { + oss << "
Numerical Constraints:
\n
    "; + for (const auto& c : numConstraints) { + if (c) { + oss << "
  • " << c->function().name() << "
  • \n"; + } + } + oss << "
\n"; + } + + return QString::fromStdString(oss.str()); +} + +QString HppNativeGraphWidget::getDetailedEdgeTargetConstraints( + std::size_t edgeId) { + if (!graph_) return QString(); + + // Find the edge by ID + auto selector = graph_->stateSelector(); + if (!selector) return QString(); + + EdgePtr_t targetEdge; + for (const auto& state : selector->getStates()) { + for (const auto& edge : state->neighborEdges()) { + if (edge && edge->id() == edgeId) { + targetEdge = edge; + break; + } + } + if (targetEdge) break; + } + + if (!targetEdge) return QString("

Edge not found

"); + + std::ostringstream oss; + oss << "

Edge Target: " << targetEdge->name() << "

\n"; + + // Get the target constraint (for reaching the target state) + auto targetConstraint = targetEdge->targetConstraint(); + if (targetConstraint) { + oss << "
Target Constraints:
\n"; + oss << "
" << *targetConstraint << "
\n"; + } + + return QString::fromStdString(oss.str()); +} + +void HppNativeGraphWidget::selectionChanged() { + QList items = scene_->selectedItems(); + currentId_ = -1; + + QString type, name; + std::size_t id = 0; + QString end; + QString constraints; + QString weight; + + if (items.size() == 0) { + // Show graph info when nothing is selected + if (graphInfo_.id > 0) { + type = "Graph"; + id = graphInfo_.id; + constraints = graphInfo_.constraintStr; + } else { + elmtInfo_->setText("No info"); + return; + } + } else if (items.size() == 1) { + QGVNode* node = dynamic_cast(items.first()); + QGVEdge* edge = dynamic_cast(items.first()); + + if (node && nodeInfos_.contains(node)) { + type = "State"; + const NodeInfo& ni = nodeInfos_[node]; + name = ni.name; + id = ni.id; + currentId_ = static_cast(id); + constraints = ni.constraintStr; + + if (ni.isWaypoint) { + end = "

This is a waypoint state

"; + } + } else if (edge && edgeInfos_.contains(edge)) { + type = "Edge"; + const EdgeInfo& ei = edgeInfos_[edge]; + name = ei.name; + id = ei.id; + currentId_ = static_cast(id); + weight = QString("
  • Weight: %1
  • ").arg(ei.weight); + constraints = ei.constraintStr; + + end = QString("

    Containing state

    \n%1

    ") + .arg(ei.containingStateName); + + if (ei.isShort) { + end.prepend("

    Short edge

    "); + } + + if (ei.nbWaypoints > 0) { + end.prepend(QString("

    This edge has %1 waypoints

    ") + .arg(ei.nbWaypoints)); + } + } else { + return; + } + } + + elmtInfo_->setText(QString("

    %1 %2

      " + "
    • Id: %3
    • " + "%4" + "
    %5%6") + .arg(type) + .arg(ESCAPE(name)) + .arg(id) + .arg(weight) + .arg(end) + .arg(constraints)); +} + +} // namespace plot +} // namespace hpp diff --git a/src/pyhpp_plot/__init__.py b/src/pyhpp_plot/__init__.py new file mode 100644 index 0000000..c5011a2 --- /dev/null +++ b/src/pyhpp_plot/__init__.py @@ -0,0 +1,21 @@ +from .graph_viewer import ( + MenuActionProxy, + show_graph, + show_graph_blocking, + show_interactive_graph, +) +from .interactive_viewer import ( + GraphViewerThread, + InteractiveGraphViewer, + show_interactive_graph_threaded, +) + +__all__ = [ + "GraphViewerThread", + "InteractiveGraphViewer", + "MenuActionProxy", + "show_graph", + "show_graph_blocking", + "show_interactive_graph", + "show_interactive_graph_threaded", +] diff --git a/src/pyhpp_plot/graph_viewer.cc b/src/pyhpp_plot/graph_viewer.cc new file mode 100644 index 0000000..96d0f29 --- /dev/null +++ b/src/pyhpp_plot/graph_viewer.cc @@ -0,0 +1,315 @@ +// BSD 2-Clause License +// +// Copyright (c) 2025, hpp-plot +// Authors: Paul Sardin +// All rights reserved. + +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: + +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. + +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in +// the documentation and/or other materials provided with the +// distribution. + +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +// OF THE POSSIBILITY OF SUCH DAMAGE. + +// clang-format off +// Boost.Python must be included before Qt headers to avoid keyword conflicts +// Prevent pre commit from reorganizing includes alphabetically +#include +#include + +#include +#include +// clang-format on + +#include +#include +#include + +// Workaround for Qt/Python keyword conflict (Python 3.13+) +// Must undef Qt keywords before including Python headers +#undef slots +#undef signals +#undef emit + +#include +#include + +namespace bp = boost::python; + +namespace { + +using GraphPtr_t = hpp::manipulation::graph::GraphPtr_t; + +// Custom message handler to suppress Qt debug messages +void quietMessageHandler(QtMsgType type, const QMessageLogContext& context, + const QString& msg) { + if (type == QtDebugMsg) { + return; // Suppress debug messages + } + // For other message types, use default behavior + QByteArray localMsg = msg.toLocal8Bit(); + switch (type) { + case QtInfoMsg: + fprintf(stderr, "%s\n", localMsg.constData()); + break; + case QtWarningMsg: + fprintf(stderr, "Warning: %s\n", localMsg.constData()); + break; + case QtCriticalMsg: + fprintf(stderr, "Critical: %s\n", localMsg.constData()); + break; + case QtFatalMsg: + fprintf(stderr, "Fatal: %s\n", localMsg.constData()); + abort(); + } +} + +static const char* GRAPH_CAPSULE_NAME = "hpp.manipulation.graph.GraphPtr"; + +/// Extract GraphPtr_t from a Python object +/// via the '_get_native_graph()' method which returns a PyCapsule +GraphPtr_t extractGraph(bp::object py_graph) { + if (PyObject_HasAttrString(py_graph.ptr(), "_get_native_graph")) { + bp::object capsule_obj = py_graph.attr("_get_native_graph")(); + PyObject* capsule = capsule_obj.ptr(); + + // Verify it's a valid capsule with the expected name + if (PyCapsule_IsValid(capsule, GRAPH_CAPSULE_NAME)) { + // Extract the GraphPtr_t* from the capsule + auto* ptr = static_cast( + PyCapsule_GetPointer(capsule, GRAPH_CAPSULE_NAME)); + if (ptr && *ptr) { + return *ptr; // Return a copy of the shared_ptr + } + } + } + + bp::extract direct_extract(py_graph); + if (direct_extract.check()) { + return direct_extract(); + } + + throw std::runtime_error( + "Cannot extract Graph from Python object. " + "Expected a Graph object from pyhpp.manipulation with " + "'_get_native_graph()' method."); +} + +/// Wrap a bp::object in a shared_ptr whose custom deleter acquires the GIL. +/// This is required because bp::object's destructor calls Py_DECREF, which +/// must only be called with the GIL held. When the QMenu is destroyed after +/// cm.exec() returns, we are inside Py_BEGIN_ALLOW_THREADS so the GIL is NOT +/// held — the custom deleter ensures safe destruction. +using SafePyObject = std::shared_ptr; +inline SafePyObject makeSafePyObject(bp::object obj) { + return SafePyObject(new bp::object(std::move(obj)), [](bp::object* p) { + PyGILState_STATE gstate = PyGILState_Ensure(); + delete p; + PyGILState_Release(gstate); + }); +} + +/// Simple wrapper to allow Python to add menu actions +class MenuActionProxy { + public: + MenuActionProxy(QMenu* menu) : menu_(menu) {} + + void addAction(const std::string& text, bp::object callback) { + QAction* action = menu_->addAction(QString::fromStdString(text)); + // Wrap callback so its destructor safely acquires the GIL + auto safe_cb = makeSafePyObject(callback); + QObject::connect(action, &QAction::triggered, [safe_cb]() { + PyGILState_STATE gstate = PyGILState_Ensure(); + try { + (*safe_cb)(); + } catch (const bp::error_already_set&) { + PyErr_Print(); + } + PyGILState_Release(gstate); + }); + } + + void addSeparator() { menu_->addSeparator(); } + + private: + QMenu* menu_; +}; + +/// This function blocks until the window is closed +void showGraphBlocking(bp::object py_graph) { + GraphPtr_t graph = extractGraph(py_graph); + + if (!graph) { + throw std::runtime_error("Graph is null"); + } + + // Suppress Qt debug output + qInstallMessageHandler(quietMessageHandler); + + int argc = 1; + static char app_name[] = "hpp-plot-native"; + char* argv[] = {app_name, nullptr}; + + QApplication app(argc, argv); + + // Create main window + QMainWindow window; + window.setWindowTitle( + QString::fromStdString("Constraint Graph: " + graph->name())); + window.resize(1200, 800); + + // Create widget + hpp::plot::HppNativeGraphWidget widget(graph, &window); + widget.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + window.setCentralWidget(&widget); + + // Show and refresh + window.show(); + widget.updateGraph(); + + // Release GIL during Qt event loop so other Python threads can run + Py_BEGIN_ALLOW_THREADS app.exec(); + Py_END_ALLOW_THREADS +} + +/// Interactive version with Python callbacks for context menus +void showInteractiveGraph(bp::object py_graph, bp::object node_callback, + bp::object edge_callback) { + GraphPtr_t graph = extractGraph(py_graph); + + if (!graph) { + throw std::runtime_error("Graph is null"); + } + + // Suppress Qt debug output + qInstallMessageHandler(quietMessageHandler); + + int argc = 1; + static char app_name[] = "hpp-plot-interactive"; + char* argv[] = {app_name, nullptr}; + + QApplication app(argc, argv); + + // Create main window + QMainWindow window; + window.setWindowTitle( + QString::fromStdString("Constraint Graph: " + graph->name())); + window.resize(1200, 800); + + // Create widget + hpp::plot::HppNativeGraphWidget widget(graph, &window); + widget.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + window.setCentralWidget(&widget); + + // Connect Qt signals to Python callbacks + // Lambdas must acquire the GIL since Qt event loop runs with GIL released. + // bp::object captures use SafePyObject so destruction is GIL-safe. + if (!node_callback.is_none()) { + auto safe_node_cb = makeSafePyObject(node_callback); + QObject::connect( + &widget, &hpp::plot::HppNativeGraphWidget::nodeContextMenuAboutToShow, + [safe_node_cb](std::size_t nodeId, QString nodeName, QMenu* menu) { + PyGILState_STATE gstate = PyGILState_Ensure(); + try { + MenuActionProxy proxy(menu); + (*safe_node_cb)(nodeId, nodeName.toStdString(), boost::ref(proxy)); + } catch (const bp::error_already_set&) { + PyErr_Print(); + } + PyGILState_Release(gstate); + }); + } + + if (!edge_callback.is_none()) { + auto safe_edge_cb = makeSafePyObject(edge_callback); + QObject::connect( + &widget, &hpp::plot::HppNativeGraphWidget::edgeContextMenuAboutToShow, + [safe_edge_cb](std::size_t edgeId, QString edgeName, QMenu* menu) { + PyGILState_STATE gstate = PyGILState_Ensure(); + try { + MenuActionProxy proxy(menu); + (*safe_edge_cb)(edgeId, edgeName.toStdString(), boost::ref(proxy)); + } catch (const bp::error_already_set&) { + PyErr_Print(); + } + PyGILState_Release(gstate); + }); + } + + // Show and refresh + window.show(); + widget.updateGraph(); + + // Release GIL during Qt event loop so other Python threads + // (e.g. ConfigQueueProcessor) can run freely + Py_BEGIN_ALLOW_THREADS app.exec(); + Py_END_ALLOW_THREADS +} + +} // namespace + +BOOST_PYTHON_MODULE(graph_viewer) { + // Append what was found at build time to QT_PLUGIN_PATH + // so users dont need to set it, even if their platform plugin + // is in non-standard place. They can still define the env + // var to override this behavior. + auto plugin_paths = qgetenv("QT_PLUGIN_PATH"); + if (!plugin_paths.isEmpty()) { + plugin_paths.append(":"); + } + plugin_paths.append(QT_PLUGIN_PATH_CMAKE); + qputenv("QT_PLUGIN_PATH", plugin_paths); + + // Expose MenuActionProxy for adding menu actions from Python + bp::class_("MenuActionProxy", + bp::no_init) + .def("addAction", &MenuActionProxy::addAction, + (bp::arg("text"), bp::arg("callback")), + "Add an action to the context menu.\n\n" + "Args:\n" + " text: The text label for the menu action\n" + " callback: Python callable to invoke when action is triggered\n") + .def("addSeparator", &MenuActionProxy::addSeparator, + "Add a separator line to the context menu\n"); + + bp::def("show_graph", &showGraphBlocking, bp::arg("graph"), + "Show constraint graph in a Qt viewer (blocking).\n\n" + "This function blocks until the viewer window is closed.\n\n" + "Args:\n" + " graph: The Graph object from pyhpp.manipulation\n"); + + // Alias for backwards compatibility + bp::def("show_graph_blocking", &showGraphBlocking, bp::arg("graph"), + "Alias for show_graph() for backwards compatibility.\n"); + + bp::def("show_interactive_graph", &showInteractiveGraph, + (bp::arg("graph"), bp::arg("node_callback") = bp::object(), + bp::arg("edge_callback") = bp::object()), + "Show constraint graph with interactive context menu callbacks.\n\n" + "This function blocks until the viewer window is closed.\n" + "Callbacks are invoked when user right-clicks on nodes/edges.\n\n" + "Args:\n" + " graph: The Graph object from pyhpp.manipulation\n" + " node_callback: Optional callback(node_id, node_name, " + "menu_proxy) for node menus\n" + " edge_callback: Optional callback(edge_id, edge_name, " + "menu_proxy) for edge menus\n"); +} diff --git a/src/pyhpp_plot/interactive_viewer.py b/src/pyhpp_plot/interactive_viewer.py new file mode 100644 index 0000000..670c1b7 --- /dev/null +++ b/src/pyhpp_plot/interactive_viewer.py @@ -0,0 +1,231 @@ +"""Interactive constraint graph viewer with Python callbacks. + +This module provides a wrapper around the native graph viewer that allows +Python code to add custom actions to context menus for nodes and edges. +Implements all features from the CORBA hpp-monitoring-plugin. +""" + +import threading + + +class InteractiveGraphViewer: + """Wrapper for HppNativeGraphWidget with Python-based context menu actions.""" + + def __init__(self, graph, problem, config_callback=None): + """Initialize the interactive graph viewer. + + Args: + graph: PyWGraph from pyhpp.manipulation + problem: PyWProblem from pyhpp.manipulation + config_callback: Optional callable(config, label) that receives + generated configurations + """ + self.graph = graph + self.problem = problem + self.config_callback = config_callback or (lambda config, label: None) + self.current_config = None + + def show(self): + """Show graph viewer (blocking - runs Qt event loop until window closes).""" + from pyhpp_plot.graph_viewer import show_interactive_graph + + show_interactive_graph( + self.graph, + node_callback=self._on_node_context_menu, + edge_callback=self._on_edge_context_menu, + ) + + def _on_node_context_menu(self, node_id, node_name, menu): + """Add custom actions to node context menu. + + Args: + node_id: ID of the node (state) - from C++ graph + node_name: Name of the node (state) - from C++ graph + menu: MenuActionProxy for adding actions + """ + menu.addSeparator() + + menu.addAction( + "&Generate random config", lambda: self._generate_random_config(node_name) + ) + + menu.addAction( + "Generate from ¤t config", + lambda: self._generate_from_current_config(node_name), + ) + + menu.addAction( + "Set as &target state", lambda: self._set_target_state(node_name) + ) + + def _on_edge_context_menu(self, edge_id, edge_name, menu): + """Add custom actions to edge context menu. + + Args: + edge_id: ID of the edge (transition) - from C++ graph + edge_name: Name of the edge (transition) - from C++ graph + menu: MenuActionProxy for adding actions + """ + menu.addSeparator() + + menu.addAction( + "&Extend current config", lambda: self._extend_current_to_current(edge_name) + ) + + menu.addAction( + "&Extend current config to random config", + lambda: self._extend_current_to_random(edge_name), + ) + + def _generate_random_config(self, state_name): + """Generate random config and project to state. + + Args: + state_name: Name of the state + """ + try: + state = self.graph.getState(state_name) + shooter = self.problem.configurationShooter() + + min_error = float("inf") + for i in range(20): + q_random = shooter.shoot() + success, q_proj, error = self.graph.applyStateConstraints( + state, q_random + ) + + if success: + self.current_config = q_proj + self.config_callback( + q_proj, f"Random config in state: {state_name}" + ) + return + + if error < min_error: + min_error = error + except Exception: + pass + + def _generate_from_current_config(self, state_name): + """Project current config to state. + + Args: + state_name: Name of the state + """ + try: + if self.current_config is None: + return + + state = self.graph.getState(state_name) + success, q_proj, _error = self.graph.applyStateConstraints( + state, self.current_config + ) + + if success: + self.current_config = q_proj + self.config_callback( + q_proj, f"Current config projected to state: {state_name}" + ) + except Exception: + pass + + def _set_target_state(self, state_name): + """Set state as goal for planning. + + Args: + state_name: Name of the state + """ + try: + state = self.graph.getState(state_name) + shooter = self.problem.configurationShooter() + + for i in range(20): + q_random = shooter.shoot() + success, q_goal, _error = self.graph.applyStateConstraints( + state, q_random + ) + + if success: + self.problem.addGoalConfig(q_goal) + return + except Exception: + pass + + def _extend_current_to_current(self, edge_name): + """Generate target config along edge from current config. + calls generateTargetConfig(edge, current, current). + + Args: + edge_name: Name of the edge + """ + try: + if self.current_config is None: + return + + edge = self.graph.getTransition(edge_name) + success, q_out, _error = self.graph.generateTargetConfig( + edge, self.current_config, self.current_config + ) + + if success: + self.current_config = q_out + self.config_callback(q_out, f"Extended along edge: {edge_name}") + except Exception: + pass + + def _extend_current_to_random(self, edge_name): + """Generate target config along edge to random config. + calls generateTargetConfig(edge, current, random). + + Args: + edge_name: Name of the edge + """ + try: + if self.current_config is None: + return + + edge = self.graph.getTransition(edge_name) + shooter = self.problem.configurationShooter() + q_random = shooter.shoot() + + success, q_out, _error = self.graph.generateTargetConfig( + edge, self.current_config, q_random + ) + + if success: + self.current_config = q_out + self.config_callback( + q_out, f"Extended along edge to random: {edge_name}" + ) + except Exception: + pass + + +class GraphViewerThread(threading.Thread): + """Runs graph viewer in separate daemon thread with Qt event loop.""" + + def __init__(self, graph, problem, config_callback): + super().__init__(daemon=True, name="GraphViewerThread") + self.graph = graph + self.problem = problem + self.config_callback = config_callback + + def run(self): + viewer = InteractiveGraphViewer(self.graph, self.problem, self.config_callback) + viewer.show() + + +def show_interactive_graph_threaded(graph, problem, config_callback): + """Show interactive graph viewer in a separate thread (non-blocking). + + Args: + graph: PyWGraph from pyhpp.manipulation + problem: PyWProblem from pyhpp.manipulation + config_callback: Callable(config, label) called when configs are generated + + Returns: + GraphViewerThread object (already started) + """ + thread = GraphViewerThread(graph, problem, config_callback) + thread.start() + return thread