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
%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