From 7f7bfc800e65aa36ca87821dc9394815ea359d14 Mon Sep 17 00:00:00 2001 From: Paul Sardin Date: Thu, 16 Jul 2026 16:47:27 +0200 Subject: [PATCH 1/7] [path-validation] Make Python validators factory-aware Store the native validator, builder, and tolerance in Python wrappers. Keep problem and edge getters on the wrapper API so object-based selection remains consistent. --- CMakeLists.txt | 1 + include/pyhpp/core/fwd.hh | 2 + include/pyhpp/core/path-validation.hh | 64 ++++++++ include/pyhpp/core/problem.hh | 3 + src/pyhpp/core/path-validation.cc | 213 ++++++++++++++++---------- src/pyhpp/core/problem.cc | 21 ++- src/pyhpp/manipulation/graph.cc | 7 +- src/pyhpp/manipulation/graph.hh | 2 + 8 files changed, 229 insertions(+), 84 deletions(-) create mode 100644 include/pyhpp/core/path-validation.hh diff --git a/CMakeLists.txt b/CMakeLists.txt index bfe561e1..b6f5930f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ set(${PROJECT_NAME}_HEADERS include/pyhpp/core/fwd.hh include/pyhpp/core/path/fwd.hh include/pyhpp/core/path-planner.hh + include/pyhpp/core/path-validation.hh include/pyhpp/core/pathOptimization/fwd.hh include/pyhpp/core/problem.hh include/pyhpp/core/problemTarget/fwd.hh diff --git a/include/pyhpp/core/fwd.hh b/include/pyhpp/core/fwd.hh index 64cc175c..36bebba0 100644 --- a/include/pyhpp/core/fwd.hh +++ b/include/pyhpp/core/fwd.hh @@ -61,7 +61,9 @@ void exposeProblem(); // forward declaration of some classes class PathPlanner; +struct PathValidation; class Problem; +typedef std::shared_ptr PyWPathValidationPtr_t; } // namespace core } // namespace pyhpp diff --git a/include/pyhpp/core/path-validation.hh b/include/pyhpp/core/path-validation.hh new file mode 100644 index 00000000..f20f8c3f --- /dev/null +++ b/include/pyhpp/core/path-validation.hh @@ -0,0 +1,64 @@ +// +// Copyright (c) 2026 CNRS +// Author: Paul Sardin +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: + +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. + +// 2. 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 PYHPP_CORE_PATH_VALIDATION_HH +#define PYHPP_CORE_PATH_VALIDATION_HH + +#include +#include +#include + +namespace pyhpp { +namespace core { + +struct PathValidation { + hpp::core::PathValidationPtr_t obj; + hpp::core::PathValidationBuilder_t factory; + hpp::core::value_type tolerance; + + PathValidation(const hpp::core::PathValidationPtr_t& obj) + : obj(obj), factory(), tolerance(0) {} + + PathValidation(const hpp::core::DevicePtr_t& robot, + const hpp::core::PathValidationBuilder_t& factory, + const hpp::core::value_type& tolerance) + : obj(factory(robot, tolerance)), + factory(factory), + tolerance(tolerance) {} + + PathValidation(const hpp::core::PathValidationPtr_t& obj, + const hpp::core::PathValidationBuilder_t& factory, + const hpp::core::value_type& tolerance) + : obj(obj), factory(factory), tolerance(tolerance) {} +}; + +} // namespace core +} // namespace pyhpp + +#endif // PYHPP_CORE_PATH_VALIDATION_HH diff --git a/include/pyhpp/core/problem.hh b/include/pyhpp/core/problem.hh index 8d79f967..c582e23f 100644 --- a/include/pyhpp/core/problem.hh +++ b/include/pyhpp/core/problem.hh @@ -73,6 +73,7 @@ struct Problem { PyWSteeringMethodPtr_t steeringMethod() const; const ConfigValidationsPtr_t& configValidation() const; PathValidationPtr_t pathValidation() const; + PyWPathValidationPtr_t pyPathValidation() const; PathProjectorPtr_t pathProjector() const; DistancePtr_t distance() const; const ProblemTargetPtr_t& target() const; @@ -81,6 +82,7 @@ struct Problem { void configValidation(const ConfigValidationsPtr_t& cv); void clearConfigValidations(); void pathValidation(const PathValidationPtr_t& pv); + void pyPathValidation(const PyWPathValidationPtr_t& pv); void pathProjector(const PathProjectorPtr_t& pp); void distance(const DistancePtr_t& d); void target(const ProblemTargetPtr_t& t); @@ -146,6 +148,7 @@ struct Problem { boost::python::tuple directPath(ConfigurationIn_t start, ConfigurationIn_t end, bool validate); hpp::core::ConstraintSetPtr_t constraints_; + PyWPathValidationPtr_t pathValidation_; value_type errorThreshold_; size_type maxIterProjection_; }; diff --git a/src/pyhpp/core/path-validation.cc b/src/pyhpp/core/path-validation.cc index ab8f8647..6daf7dd8 100644 --- a/src/pyhpp/core/path-validation.cc +++ b/src/pyhpp/core/path-validation.cc @@ -1,6 +1,6 @@ // -// Copyright (c) 2018 - 2023, CNRS -// Authors: Joseph Mirabel, Florent Lamiraux +// Copyright (c) 2018 - 2026, CNRS +// Authors: Joseph Mirabel, Florent Lamiraux, Paul Sardin // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions @@ -33,108 +33,163 @@ #include #include #include -#include #include #include #include -#include -#include +#include +#include #include // DocNamespace(hpp::core) -using namespace boost::python; - namespace pyhpp { namespace core { -using namespace hpp::core; + +using namespace boost::python; + +namespace { + +hpp::core::PathValidationPtr_t createDiscretizedJointBoundAndCollisionChecking( + const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& stepSize) { + return hpp::core::pathValidation::Discretized::create( + stepSize, {hpp::core::JointBoundValidation::create(robot), + hpp::core::CollisionValidation::create(robot)}); +} + +const hpp::core::PathValidationBuilder_t& noValidationFactory() { + static const hpp::core::PathValidationBuilder_t factory = + hpp::core::ProblemSolver::create()->pathValidations.get("NoValidation"); + return factory; +} struct PVWrapper { - static bool validate(PathValidation* pv, const PathPtr_t path, bool reverse, - PathPtr_t& validPart, - PathValidationReportPtr_t& report) { - return pv->validate(path, reverse, validPart, report); + static bool validate(PathValidation* pv, const hpp::core::PathPtr_t path, + bool reverse, hpp::core::PathPtr_t& validPart, + hpp::core::PathValidationReportPtr_t& report) { + return pv->obj->validate(path, reverse, validPart, report); } - static tuple py_validate(PathValidation* pv, const PathPtr_t path, - bool reverse = false) { - PathPtr_t validPart; - PathValidationReportPtr_t report; - bool res = pv->validate(path, reverse, validPart, report); - return boost::python::make_tuple(res, validPart, report); - } - static tuple validateConfiguration(PathValidation* pv, ConfigurationIn_t q) { - ValidationReportPtr_t report; - bool res = pv->validate(q, report); - return boost::python::make_tuple(res, report); + static tuple pyValidate(PathValidation* pv, const hpp::core::PathPtr_t path, + bool reverse = false) { + hpp::core::PathPtr_t validPart; + hpp::core::PathValidationReportPtr_t report; + bool result = pv->obj->validate(path, reverse, validPart, report); + return boost::python::make_tuple(result, validPart, report); } - static pathValidation::DiscretizedPtr_t - createDiscretizedJointBoundAndCollisionChecking(const DevicePtr_t& robot, - const value_type& stepSize) { - using namespace pathValidation; - return Discretized::create(stepSize, - { - JointBoundValidation::create(robot), - CollisionValidation::create(robot), - }); + static tuple validateConfiguration(PathValidation* pv, + hpp::core::ConfigurationIn_t q) { + hpp::core::ValidationReportPtr_t report; + bool result = pv->obj->validate(q, report); + return boost::python::make_tuple(result, report); } }; +namespace pathValidation { + +struct NoValidation : PathValidation { + NoValidation(const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& tolerance) + : PathValidation(robot, noValidationFactory(), tolerance) {} +}; + +struct Discretized : PathValidation { + Discretized(const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& stepSize) + : PathValidation( + robot, + hpp::core::pathValidation::createDiscretizedCollisionChecking, + stepSize) {} +}; + +struct DiscretizedCollision : PathValidation { + DiscretizedCollision(const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& stepSize) + : PathValidation( + robot, + hpp::core::pathValidation::createDiscretizedCollisionChecking, + stepSize) {} +}; + +struct DiscretizedJointBound : PathValidation { + DiscretizedJointBound(const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& stepSize) + : PathValidation(robot, + hpp::core::pathValidation::createDiscretizedJointBound, + stepSize) {} +}; + +struct DiscretizedCollisionAndJointBound : PathValidation { + DiscretizedCollisionAndJointBound(const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& stepSize) + : PathValidation(robot, createDiscretizedJointBoundAndCollisionChecking, + stepSize) {} +}; + +struct Progressive : PathValidation { + Progressive(const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& tolerance) + : PathValidation(robot, + hpp::core::continuousValidation::Progressive::create, + tolerance) {} +}; + +struct Dichotomy : PathValidation { + Dichotomy(const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& tolerance) + : PathValidation(robot, + hpp::core::continuousValidation::Dichotomy::create, + tolerance) {} +}; + +} // namespace pathValidation +} // namespace + void exposePathValidation() { + register_ptr_to_python(); + // DocClass(PathValidation) - class_( + class_( "PathValidation", DocClassDoc(), no_init) .def("validate", &PVWrapper::validate, DocClassMethod(validate)) - .def("validate", &PVWrapper::py_validate, + .def("validate", &PVWrapper::pyValidate, "Validate path; returns (valid, validPart, report).") .def("validateConfiguration", &PVWrapper::validateConfiguration, "Validate a configuration; returns (valid, report)."); - class_, - hpp::core::pathValidation::DiscretizedPtr_t, boost::noncopyable>( - "Discretized", DocClassDoc(), no_init) - .def("__init__", - make_constructor( - +[](const DevicePtr_t& robot, const value_type& stepSize) { - return pathValidation::createDiscretizedCollisionChecking( - robot, stepSize); - }, - default_call_policies(), (arg("robot"), arg("stepSize"))), - "Create a discretized collision-checking path validation."); - - hpp::core::continuousValidation::ProgressivePtr_t (*ProgressiveConstructor)( - const DevicePtr_t&, const value_type&) = - &continuousValidation::Progressive::create; - class_, - hpp::core::continuousValidation::ProgressivePtr_t, boost::noncopyable>( - "Progressive", DocClassDoc(), no_init) - .def("__init__", - make_constructor(ProgressiveConstructor, default_call_policies(), - (arg("robot"), arg("tolerance"))), - "Create a progressive continuous path validation."); - - hpp::core::continuousValidation::DichotomyPtr_t (*DichotomyConstructor)( - const DevicePtr_t&, const value_type&) = - &continuousValidation::Dichotomy::create; - class_, - hpp::core::continuousValidation::DichotomyPtr_t, boost::noncopyable>( - "Dichotomy", DocClassDoc(), no_init) - .def("__init__", - make_constructor(DichotomyConstructor, default_call_policies(), - (arg("robot"), arg("tolerance"))), - "Create a dichotomy-based continuous path validation."); - - def("DiscretizedCollision", - &pathValidation::createDiscretizedCollisionChecking, - (arg("robot"), arg("stepSize")), - "Create a discretized collision-checking path validation."); - def("DiscretizedJointBound", &pathValidation::createDiscretizedJointBound, - (arg("robot"), arg("stepSize")), - "Create a discretized joint-bound path validation."); - def("DiscretizedCollisionAndJointBound", - &PVWrapper::createDiscretizedJointBoundAndCollisionChecking, - (arg("robot"), arg("stepSize")), + class_>( + "NoValidation", "Create a path validation that accepts every path.", + init( + (arg("robot"), arg("tolerance")))); + class_>( + "Discretized", "Create a discretized collision-checking path validation.", + init( + (arg("robot"), arg("stepSize")))); + class_>( + "DiscretizedCollision", + "Create a discretized collision-checking path validation.", + init( + (arg("robot"), arg("stepSize")))); + class_>( + "DiscretizedJointBound", + "Create a discretized joint-bound path validation.", + init( + (arg("robot"), arg("stepSize")))); + class_>( + "DiscretizedCollisionAndJointBound", "Create a discretized path validation checking both collision and joint " - "bounds."); + "bounds.", + init( + (arg("robot"), arg("stepSize")))); + class_>( + "Progressive", "Create a progressive continuous path validation.", + init( + (arg("robot"), arg("tolerance")))); + class_>( + "Dichotomy", "Create a dichotomy-based continuous path validation.", + init( + (arg("robot"), arg("tolerance")))); } + } // namespace core } // namespace pyhpp diff --git a/src/pyhpp/core/problem.cc b/src/pyhpp/core/problem.cc index 474b4aaa..494175b1 100644 --- a/src/pyhpp/core/problem.cc +++ b/src/pyhpp/core/problem.cc @@ -51,6 +51,7 @@ #include #include #include +#include #include namespace { @@ -171,6 +172,12 @@ PathValidationPtr_t Problem::pathValidation() const { return obj->pathValidation(); } +PyWPathValidationPtr_t Problem::pyPathValidation() const { + if (pathValidation_ && pathValidation_->obj == obj->pathValidation()) + return pathValidation_; + return std::make_shared(obj->pathValidation()); +} + PathProjectorPtr_t Problem::pathProjector() const { return obj->pathProjector(); } @@ -193,6 +200,12 @@ void Problem::configValidation(const ConfigValidationsPtr_t& cv) { void Problem::pathValidation(const PathValidationPtr_t& pv) { obj->pathValidation(pv); + pathValidation_.reset(); +} + +void Problem::pyPathValidation(const PyWPathValidationPtr_t& pv) { + obj->pathValidation(pv->obj); + pathValidation_ = pv; } void Problem::pathProjector(const PathProjectorPtr_t& pp) { @@ -586,8 +599,8 @@ typedef void (Problem::*SetSteeringMethod)(const PyWSteeringMethodPtr_t&); typedef const ConfigValidationsPtr_t& (Problem::*GetConfigValidation)() const; typedef void (Problem::*SetConfigValidation)(const ConfigValidationsPtr_t&); -typedef PathValidationPtr_t (Problem::*GetPathValidation)() const; -typedef void (Problem::*SetPathValidation)(const PathValidationPtr_t&); +typedef PyWPathValidationPtr_t (Problem::*GetPathValidation)() const; +typedef void (Problem::*SetPathValidation)(const PyWPathValidationPtr_t&); typedef PathProjectorPtr_t (Problem::*GetPathProjector)() const; typedef void (Problem::*SetPathProjector)(const PathProjectorPtr_t&); @@ -692,10 +705,10 @@ void exposeProblem() { DocClassMethod(clearConfigValidations)) .def("pathValidation", - static_cast(&Problem::pathValidation), + static_cast(&Problem::pyPathValidation), DOC_P_PV_GET) .def("pathValidation", - static_cast(&Problem::pathValidation), + static_cast(&Problem::pyPathValidation), (arg("pathValidation")), DOC_P_PV_SET) .def("pathProjector", diff --git a/src/pyhpp/manipulation/graph.cc b/src/pyhpp/manipulation/graph.cc index 37ad8cfe..a123c68c 100644 --- a/src/pyhpp/manipulation/graph.cc +++ b/src/pyhpp/manipulation/graph.cc @@ -44,6 +44,7 @@ #include #include "hpp/manipulation/constraint-set.hh" +#include "pyhpp/core/path-validation.hh" #include "pyhpp/core/problem.hh" // DocNamespace(hpp::manipulation::graph) @@ -952,6 +953,10 @@ PathValidationPtr_t PyWEdge::pathValidation() const { return obj->pathValidation(); } +pyhpp::core::PyWPathValidationPtr_t PyWEdge::pyPathValidation() const { + return std::make_shared(obj->pathValidation()); +} + // ============================================================================= // Subgraph management // ============================================================================= @@ -1305,7 +1310,7 @@ void exposeGraph() { .def("nbWaypoints", &PyWEdge::nbWaypoints, DocClassMethod(nbWaypoints)) .def("waypoint", &PyWEdge::waypoint, DocClassMethod(waypoint)) .def("nbWaypoints", &PyWEdge::nbWaypoints, DocClassMethod(nbWaypoints)) - .def("pathValidation", &PyWEdge::pathValidation, + .def("pathValidation", &PyWEdge::pyPathValidation, DocClassMethod(pathValidation)); // DocClass(Graph) diff --git a/src/pyhpp/manipulation/graph.hh b/src/pyhpp/manipulation/graph.hh index 47779f9c..f7d62219 100644 --- a/src/pyhpp/manipulation/graph.hh +++ b/src/pyhpp/manipulation/graph.hh @@ -32,6 +32,7 @@ #include #include +#include #include namespace pyhpp { @@ -76,6 +77,7 @@ struct PyWEdge { std::size_t weight() const; PyWEdge waypoint(int index) const; PathValidationPtr_t pathValidation() const; + pyhpp::core::PyWPathValidationPtr_t pyPathValidation() const; }; typedef std::shared_ptr PyWEdgePtr_t; From 209a125243c2ce365cca8541ee74f8295a00e6ee Mon Sep 17 00:00:00 2001 From: Paul Sardin Date: Thu, 16 Jul 2026 16:49:23 +0200 Subject: [PATCH 2/7] [manipulation] Synchronize path validation factories Install the selected validator on the manipulation problem and retain its builder and tolerance for graph edges. Add existing obstacles before updating the factory. --- src/pyhpp/manipulation/problem.cc | 36 +++++++++++++++++++++++++++++++ src/pyhpp/manipulation/problem.hh | 3 +++ 2 files changed, 39 insertions(+) diff --git a/src/pyhpp/manipulation/problem.cc b/src/pyhpp/manipulation/problem.cc index 57856cff..731896fd 100644 --- a/src/pyhpp/manipulation/problem.cc +++ b/src/pyhpp/manipulation/problem.cc @@ -33,11 +33,13 @@ #include <../src/pyhpp/manipulation/steering-method.hh> #include #include +#include #include #include #include #include #include +#include #include // DocNamespace(hpp::manipulation) @@ -110,6 +112,29 @@ pyhpp::core::PyWSteeringMethodPtr_t Problem::steeringMethod() const { return std::shared_ptr(sm); } +pyhpp::core::PyWPathValidationPtr_t Problem::pyPathValidation() const { + if (pathValidation_ && pathValidation_->obj == obj->pathValidation()) + return pathValidation_; + return std::make_shared(obj->pathValidation()); +} + +void Problem::pyPathValidation( + const pyhpp::core::PyWPathValidationPtr_t& pathValidation) { + if (!pathValidation || !pathValidation->factory) + throw std::invalid_argument("Path validation has no factory."); + hpp::manipulation::ProblemPtr_t problem = asManipulationProblem(); + auto obstacleUser = HPP_DYNAMIC_PTR_CAST(hpp::core::ObstacleUserInterface, + pathValidation->obj); + if (obstacleUser) + for (const auto& obstacle : problem->collisionObstacles()) + obstacleUser->addObstacle(obstacle); + + problem->pathValidation(pathValidation->obj); + pathValidation_ = pathValidation; + problem->setPathValidationFactory(pathValidation->factory, + pathValidation->tolerance); +} + // PathValidationPtr_t Problem::pathValidation() const { // return obj->pathValidation(); // } @@ -173,6 +198,17 @@ void exposeProblem() { "Set the problem steering method directly. Unlike steeringMethod, " "this does not wrap the given steering method in a manipulation " "graph steering method.") + .def( + "pathValidation", + static_cast( + &Problem::pyPathValidation), + "Get the path validation object.") + .def("pathValidation", + static_cast( + &Problem::pyPathValidation), + (arg("pathValidation")), + "Set the path validation object and edge-validation factory.") // .PYHPP_DEFINE_GETTER_SETTER_CONST_REF(Problem, pathValidation, // PathValidationPtr_t) .PYHPP_DEFINE_METHOD(Problem, // manipulationSteeringMethod) .PYHPP_DEFINE_METHOD(Problem, diff --git a/src/pyhpp/manipulation/problem.hh b/src/pyhpp/manipulation/problem.hh index d2fbf0bc..e7943809 100644 --- a/src/pyhpp/manipulation/problem.hh +++ b/src/pyhpp/manipulation/problem.hh @@ -62,6 +62,9 @@ struct Problem : public pyhpp::core::Problem { pyhpp::core::PyWSteeringMethodPtr_t steeringMethod() const; void fullSteeringMethod( const pyhpp::core::PyWSteeringMethodPtr_t& steeringMethod); + pyhpp::core::PyWPathValidationPtr_t pyPathValidation() const; + void pyPathValidation( + const pyhpp::core::PyWPathValidationPtr_t& pathValidation); // PathValidationPtr_t pathValidation() const; // void pathValidation (const PathValidationPtr_t &pathValidation); // SteeringMethodPtr_t manipulationSteeringMethod() const; From c0d0e39a009adc97691be6f2682e0ea8ef114b9b Mon Sep 17 00:00:00 2001 From: Paul Sardin Date: Thu, 16 Jul 2026 16:50:01 +0200 Subject: [PATCH 3/7] [manipulation] Expose GraphPathValidation Compose any factory-aware core validator with graph path validation while preserving the inner factory used for graph edges. --- src/pyhpp/manipulation/problem.cc | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/pyhpp/manipulation/problem.cc b/src/pyhpp/manipulation/problem.cc index 731896fd..fc3924e5 100644 --- a/src/pyhpp/manipulation/problem.cc +++ b/src/pyhpp/manipulation/problem.cc @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,39 @@ using namespace boost::python; namespace pyhpp { namespace manipulation { +namespace { + +struct GraphPathValidation : pyhpp::core::PathValidation { + GraphPathValidation(const pyhpp::core::PyWPathValidationPtr_t& pathValidation) + : GraphPathValidation(check(pathValidation), 0) {} + + private: + GraphPathValidation(const pyhpp::core::PyWPathValidationPtr_t& pathValidation, + int) + : pyhpp::core::PathValidation( + hpp::manipulation::GraphPathValidation::create(pathValidation->obj), + graphFactory(pathValidation->factory), pathValidation->tolerance) {} + + static pyhpp::core::PyWPathValidationPtr_t check( + const pyhpp::core::PyWPathValidationPtr_t& pathValidation) { + if (!pathValidation || !pathValidation->factory) + throw std::invalid_argument("Path validation has no factory."); + return pathValidation; + } + + static hpp::core::PathValidationBuilder_t graphFactory( + const hpp::core::PathValidationBuilder_t& innerFactory) { + return [innerFactory](const hpp::core::DevicePtr_t& robot, + const hpp::core::value_type& tolerance) + -> hpp::core::PathValidationPtr_t { + return hpp::manipulation::GraphPathValidation::create( + innerFactory(robot, tolerance)); + }; + } +}; + +} // namespace + Problem::Problem(const PyWDevicePtr_t& robot) : pyhpp::core::Problem( hpp::manipulation::Problem::create(robot->asManipulationDevice())) {} @@ -170,6 +204,10 @@ void Problem::pyPathValidation( // } void exposeProblem() { + class_>( + "GraphPathValidation", init( + (arg("pathValidation")))); + // DocClass(Problem) class_>("Problem", DocClassDoc(), init()) From 6511bd476994fab4cf4f70e10f562074ba5422c0 Mon Sep 17 00:00:00 2001 From: Paul Sardin Date: Wed, 15 Jul 2026 11:00:08 +0200 Subject: [PATCH 4/7] [doc] Preserve nested Doxygen descriptions --- doc/doxygen_xml_parser.py | 8 +--- tests/unit/test_doxygen_xml_parser.py | 63 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_doxygen_xml_parser.py diff --git a/doc/doxygen_xml_parser.py b/doc/doxygen_xml_parser.py index 26812e70..85148562 100644 --- a/doc/doxygen_xml_parser.py +++ b/doc/doxygen_xml_parser.py @@ -34,7 +34,7 @@ def _getDoc(el): b = el.find("briefdescription") d = el.find("detaileddescription") brief = etree.tostring(b, method="text", encoding="unicode").strip() - detailed = d.text.strip() if d.text else "" + detailed = etree.tostring(d, method="text", encoding="unicode").strip() return brief, detailed def _getMemberFromGroup(self, memberDefKind, name): @@ -124,11 +124,7 @@ def _getMember(self, sectionKind, memberDefKind, name): raise IndexError(msg) def getClassDoc(self): - b_el = self.compound.find("briefdescription") - d_el = self.compound.find("detaileddescription") - brief = etree.tostring(b_el, method="text", encoding="unicode").strip() - detailed = etree.tostring(d_el, method="text", encoding="unicode").strip() - return brief, detailed + return self._getDoc(self.compound) def getClassMemberDoc(self, membername): # member = self.compound.find ("sectiondef[@kind='public-attrib']/memberdef[@kind='variable' and name='" + methodname + "']") diff --git a/tests/unit/test_doxygen_xml_parser.py b/tests/unit/test_doxygen_xml_parser.py new file mode 100644 index 00000000..91edced4 --- /dev/null +++ b/tests/unit/test_doxygen_xml_parser.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 CNRS +# Author: Paul Sardin +# + +import tempfile +import unittest +from pathlib import Path + +from doc.doxygen_xml_parser import ClassDoc + + +DOXYGEN_XML = """\ + + + hpp::core::Planner + + Class brief. + + + Class details. + + + + startSolve + + Start solving. + + + Use the child planner. + + + + + +""" + + +class TestDoxygenXmlParser(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + filename = Path(self.temporary_directory.name) / "planner.xml" + filename.write_text(DOXYGEN_XML) + self.class_doc = ClassDoc(filename) + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_nested_class_description(self): + self.assertEqual( + self.class_doc.getClassDoc(), ("Class brief.", "Class details.") + ) + + def test_nested_method_description(self): + self.assertEqual( + self.class_doc.getClassMethodDoc("startSolve"), + ("Start solving.", "Use the child planner.", ["self"]), + ) + + +if __name__ == "__main__": + unittest.main() From 37f52b288a905e971c99308c1ad18b2f348ba4f2 Mon Sep 17 00:00:00 2001 From: Paul Sardin Date: Thu, 16 Jul 2026 08:10:45 +0200 Subject: [PATCH 5/7] [path-planner] Document concrete planner overrides --- src/pyhpp/core/path-planner.cc | 100 +++++++++++++++++++------ src/pyhpp/manipulation/path-planner.cc | 46 +++++++++++- 2 files changed, 121 insertions(+), 25 deletions(-) diff --git a/src/pyhpp/core/path-planner.cc b/src/pyhpp/core/path-planner.cc index 32526847..b37f6796 100644 --- a/src/pyhpp/core/path-planner.cc +++ b/src/pyhpp/core/path-planner.cc @@ -55,27 +55,70 @@ using namespace boost::python; namespace pathPlanner { -#define DEFINE_PLANNER_WRAPPER(WrapperName, PlannerType, PlannerPtr) \ - struct WrapperName : public pyhpp::core::PathPlanner { \ - WrapperName(const pyhpp::core::Problem& problem) { \ - obj = PlannerType::create(problem.obj); \ - } \ - }; \ - void expose##WrapperName() { \ - class_>( \ - #WrapperName, init()); \ +#define DEFINE_PLANNER_WRAPPER(WrapperName, PlannerType) \ + struct WrapperName : public pyhpp::core::PathPlanner { \ + WrapperName(const pyhpp::core::Problem& problem) { \ + obj = PlannerType::create(problem.obj); \ + } \ } -DEFINE_PLANNER_WRAPPER(DiffusingPlanner, hpp::core::DiffusingPlanner, - hpp::core::DiffusingPlannerPtr_t) -DEFINE_PLANNER_WRAPPER(BiRRTPlanner, hpp::core::BiRRTPlanner, - hpp::core::BiRRTPlannerPtr_t) -DEFINE_PLANNER_WRAPPER(VisibilityPrmPlanner, hpp::core::VisibilityPrmPlanner, - hpp::core::VisibilityPrmPlannerPtr_t) -DEFINE_PLANNER_WRAPPER(BiRrtStar, hpp::core::pathPlanner::BiRrtStar, - hpp::core::pathPlanner::BiRrtStarPtr_t) -DEFINE_PLANNER_WRAPPER(kPrmStar, hpp::core::pathPlanner::kPrmStar, - hpp::core::pathPlanner::kPrmStarPtr_t) +DEFINE_PLANNER_WRAPPER(DiffusingPlanner, hpp::core::DiffusingPlanner); +DEFINE_PLANNER_WRAPPER(BiRRTPlanner, hpp::core::BiRRTPlanner); +DEFINE_PLANNER_WRAPPER(VisibilityPrmPlanner, hpp::core::VisibilityPrmPlanner); +DEFINE_PLANNER_WRAPPER(BiRrtStar, hpp::core::pathPlanner::BiRrtStar); +DEFINE_PLANNER_WRAPPER(kPrmStar, hpp::core::pathPlanner::kPrmStar); + +// DocClass(DiffusingPlanner) +void exposeDiffusingPlanner() { + class_>( + "DiffusingPlanner", DocClassDoc(), init()) + .def("startSolve", &pyhpp::core::PathPlanner::startSolve, + DocClassMethod(startSolve)) + .def("oneStep", &pyhpp::core::PathPlanner::oneStep, + DocClassMethod(oneStep)); +} + +// DocClass(BiRRTPlanner) +void exposeBiRRTPlanner() { + class_>( + "BiRRTPlanner", DocClassDoc(), init()) + .def("startSolve", &pyhpp::core::PathPlanner::startSolve, + DocClassMethod(startSolve)) + .def("oneStep", &pyhpp::core::PathPlanner::oneStep, + DocClassMethod(oneStep)); +} + +// DocClass(VisibilityPrmPlanner) +void exposeVisibilityPrmPlanner() { + class_>( + "VisibilityPrmPlanner", DocClassDoc(), + init()) + .def("oneStep", &pyhpp::core::PathPlanner::oneStep, + DocClassMethod(oneStep)); +} + +// DocClass(pathPlanner::BiRrtStar) +void exposeBiRrtStar() { + class_>( + "BiRrtStar", DocClassDoc(), init()) + .def("startSolve", &pyhpp::core::PathPlanner::startSolve, + DocClassMethod(startSolve)) + .def("oneStep", &pyhpp::core::PathPlanner::oneStep, + DocClassMethod(oneStep)); +} + +// DocClass(pathPlanner::kPrmStar) +void exposekPrmStar() { + class_>( + "kPrmStar", DocClassDoc(), init()) + .def("startSolve", &pyhpp::core::PathPlanner::startSolve, + DocClassMethod(startSolve)) + .def("tryConnectInitAndGoals", + &pyhpp::core::PathPlanner::tryConnectInitAndGoals, + DocClassMethod(tryConnectInitAndGoals)) + .def("oneStep", &pyhpp::core::PathPlanner::oneStep, + DocClassMethod(oneStep)); +} struct SearchInRoadmap : public pyhpp::core::PathPlanner { SearchInRoadmap(const pyhpp::core::Problem& problem, @@ -85,10 +128,16 @@ struct SearchInRoadmap : public pyhpp::core::PathPlanner { } }; +// DocClass(pathPlanner::SearchInRoadmap) void exposeSearchInRoadmap() { class_>( - "SearchInRoadmap", - init()); + "SearchInRoadmap", DocClassDoc(), + init()) + .def("tryConnectInitAndGoals", + &pyhpp::core::PathPlanner::tryConnectInitAndGoals, + DocClassMethod(tryConnectInitAndGoals)) + .def("oneStep", &pyhpp::core::PathPlanner::oneStep, + DocClassMethod(oneStep)); } struct PlanAndOptimize : public pyhpp::core::PathPlanner { @@ -97,9 +146,16 @@ struct PlanAndOptimize : public pyhpp::core::PathPlanner { } }; +// DocClass(PlanAndOptimize) void exposePlanAndOptimize() { class_>( - "PlanAndOptimize", init()); + "PlanAndOptimize", DocClassDoc(), init()) + .def("startSolve", &pyhpp::core::PathPlanner::startSolve, + DocClassMethod(startSolve)) + .def("oneStep", &pyhpp::core::PathPlanner::oneStep, + DocClassMethod(oneStep)) + .def("finishSolve", &pyhpp::core::PathPlanner::finishSolve, + DocClassMethod(finishSolve)); } void exposePathPlanners() { diff --git a/src/pyhpp/manipulation/path-planner.cc b/src/pyhpp/manipulation/path-planner.cc index d4c2d522..842b8b30 100644 --- a/src/pyhpp/manipulation/path-planner.cc +++ b/src/pyhpp/manipulation/path-planner.cc @@ -56,6 +56,21 @@ const char* DOC_CHECKFEASIBILITYONLY = "If enabled, only add one solution to the roadmap. " "Otherwise add all solutions."; +template +void plannerStartSolve(Planner& planner) { + planner.obj->startSolve(); +} + +template +void plannerTryConnectInitAndGoals(Planner& planner) { + planner.obj->tryConnectInitAndGoals(); +} + +template +void plannerOneStep(Planner& planner) { + planner.obj->oneStep(); +} + } // namespace namespace pyhpp { @@ -322,6 +337,10 @@ void exposePathPlanners() { boost::python::bases>( "TransitionPlanner", DocClassDoc(), boost::python::init()) + .def("startSolve", &plannerStartSolve, + DocClassMethod(startSolve)) + .def("oneStep", &plannerOneStep, + DocClassMethod(oneStep)) .def("innerPlanner", static_cast( &TransitionPlanner::innerPlanner), @@ -360,14 +379,28 @@ void exposePathPlanners() { .def("addPathOptimizer", &TransitionPlanner::addPathOptimizer, DocClassMethod(addPathOptimizer)); + // DocNamespace(hpp::manipulation) + // DocClass(ManipulationPlanner) boost::python::class_>( - "ManipulationPlanner", - boost::python::init()); + "ManipulationPlanner", DocClassDoc(), + boost::python::init()) + .def("oneStep", &plannerOneStep, + DocClassMethod(oneStep)); + // DocNamespace(hpp::manipulation::pathPlanner) + // DocClass(StatesPathFinder) boost::python::class_>( - "StatesPathFinder", boost::python::init()); + "StatesPathFinder", DocClassDoc(), + boost::python::init()) + .def("startSolve", &plannerStartSolve, + DocClassMethod(startSolve)) + .def("tryConnectInitAndGoals", + &plannerTryConnectInitAndGoals, + DocClassMethod(tryConnectInitAndGoals)) + .def("oneStep", &plannerOneStep, + DocClassMethod(oneStep)); // DocClass(EndEffectorTrajectory) boost::python::class_()) .def(boost::python::init()) + .def("startSolve", &plannerStartSolve, + DocClassMethod(startSolve)) + .def("tryConnectInitAndGoals", + &plannerTryConnectInitAndGoals, + DocClassMethod(tryConnectInitAndGoals)) + .def("oneStep", &plannerOneStep, + DocClassMethod(oneStep)) .def("nRandomConfig", static_cast( &EndEffectorTrajectory::nRandomConfig), From c9ed8c11709b6f4fabceaf6cd0e057a77de8c147 Mon Sep 17 00:00:00 2001 From: Paul Sardin Date: Mon, 27 Jul 2026 09:32:39 +0200 Subject: [PATCH 6/7] [doc] Resolve inherited API members --- doc/gen_api_md.py | 203 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 180 insertions(+), 23 deletions(-) diff --git a/doc/gen_api_md.py b/doc/gen_api_md.py index cc1df883..498227ee 100644 --- a/doc/gen_api_md.py +++ b/doc/gen_api_md.py @@ -294,6 +294,11 @@ def _unparse_ann(ann: ast.expr | None) -> str: # --------------------------------------------------------------------------- ClassIndex = dict[str, str] # type name variants → "page.md#anchor" +ClassDefinitionIndex = dict[str, ast.ClassDef] + + +def _stub_module_name(relative_stub: str) -> str: + return str(Path(relative_stub).with_suffix("")).replace("/", ".") def build_class_index(stubs_root: Path) -> ClassIndex: @@ -307,7 +312,7 @@ def build_class_index(stubs_root: Path) -> ClassIndex: if tree is None: continue page = slug(module_name) - stub_dotted = str(Path(rel_stub).with_suffix("")).replace("/", ".") + stub_dotted = _stub_module_name(rel_stub) for item in tree.body: if not isinstance(item, ast.ClassDef): continue @@ -334,6 +339,30 @@ def build_class_index(stubs_root: Path) -> ClassIndex: return index +def build_class_definition_index(stubs_root: Path) -> ClassDefinitionIndex: + index: ClassDefinitionIndex = {} + + def add_class(class_node: ast.ClassDef, parent: str) -> None: + qualified_class = f"{parent}.{class_node.name}" + index[qualified_class] = class_node + for item in class_node.body: + if isinstance(item, ast.ClassDef): + add_class(item, qualified_class) + + for rel_stub, _, _ in MODULES: + stub_path = stubs_root / rel_stub + if not stub_path.exists(): + continue + tree = _parse_stub(stub_path) + if tree is None: + continue + stub_module = _stub_module_name(rel_stub) + for item in tree.body: + if isinstance(item, ast.ClassDef): + add_class(item, stub_module) + return index + + def _type_link(type_str: str, class_index: ClassIndex, current_page: str) -> str: """Markdown link for a type (used outside code blocks, e.g. base-class line).""" if not type_str: @@ -410,11 +439,30 @@ def _fmt_ann(ann: str, class_index: ClassIndex, current_page: str) -> str: def _build_stub_module_index() -> dict[str, str]: index: dict[str, str] = {} for rel_stub, module_name, _ in MODULES: - dotted = str(Path(rel_stub).with_suffix("")).replace("/", ".") - index[dotted] = slug(module_name) + index[_stub_module_name(rel_stub)] = slug(module_name) return index +def _base_name(base: ast.expr) -> str: + if isinstance(base, ast.Attribute): + return ast.unparse(base) + if isinstance(base, ast.Name): + return base.id + return "" + + +def _resolve_base( + base: ast.expr, + current_stub_module: str, + class_definitions: ClassDefinitionIndex, +) -> str | None: + raw = _base_name(base) + if not raw: + return None + qualified = raw if "." in raw else f"{current_stub_module}.{raw}" + return qualified if qualified in class_definitions else None + + def _base_links( class_node: ast.ClassDef, stub_module_index: dict[str, str], @@ -423,11 +471,7 @@ def _base_links( ) -> list[str]: links = [] for b in class_node.bases: - raw = ( - ast.unparse(b) - if isinstance(b, ast.Attribute) - else (b.id if isinstance(b, ast.Name) else "") - ) + raw = _base_name(b) if not raw: continue class_name = raw.split(".")[-1] @@ -449,6 +493,7 @@ def _base_links( # --------------------------------------------------------------------------- type BodyGroup = list[ast.FunctionDef] | ast.ClassDef +type ClassMethods = dict[str, tuple[list[ast.FunctionDef], str]] def _group_body(body: list[ast.stmt]) -> list[BodyGroup]: @@ -473,6 +518,80 @@ def _group_body(body: list[ast.stmt]) -> list[BodyGroup]: return result +def _class_mro( + qualified_class: str, + class_definitions: ClassDefinitionIndex, + cache: dict[str, list[str]], +) -> list[str]: + if qualified_class in cache: + return cache[qualified_class] + + class_node = class_definitions[qualified_class] + current_stub_module = qualified_class.rsplit(".", 1)[0] + bases = [ + resolved + for base in class_node.bases + if (resolved := _resolve_base(base, current_stub_module, class_definitions)) + ] + sequences = [_class_mro(base, class_definitions, cache).copy() for base in bases] + sequences.append(bases.copy()) + + result = [qualified_class] + while sequences: + sequences = [sequence for sequence in sequences if sequence] + if not sequences: + break + candidate = next( + ( + sequence[0] + for sequence in sequences + if not any(sequence[0] in other[1:] for other in sequences) + ), + None, + ) + if candidate is None: + raise ValueError(f"Inconsistent class hierarchy for {qualified_class}") + result.append(candidate) + for sequence in sequences: + if sequence[0] == candidate: + sequence.pop(0) + + cache[qualified_class] = result + return result + + +def _direct_method_groups( + class_node: ast.ClassDef, +) -> dict[str, list[ast.FunctionDef]]: + methods: dict[str, list[ast.FunctionDef]] = {} + for group in _group_body(class_node.body): + if isinstance(group, ast.ClassDef): + continue + node = group[0] + methods.setdefault(node.name, []).extend(group) + return methods + + +def _collect_class_methods( + qualified_class: str, + class_definitions: ClassDefinitionIndex, + cache: dict[str, ClassMethods], + mro_cache: dict[str, list[str]], +) -> ClassMethods: + if qualified_class in cache: + return cache[qualified_class] + + methods: ClassMethods = {} + for owner in _class_mro(qualified_class, class_definitions, mro_cache): + for name, group in _direct_method_groups(class_definitions[owner]).items(): + if name == "__init__" and owner != qualified_class: + continue + methods.setdefault(name, (group, owner)) + + cache[qualified_class] = methods + return methods + + # --------------------------------------------------------------------------- # Signature rendering — HTML
 with embedded  links
 # ---------------------------------------------------------------------------
@@ -664,6 +783,8 @@ def render_class(
     class_index: ClassIndex,
     current_page: str,
     stub_module_index: dict[str, str],
+    qualified_class: str,
+    class_methods: dict[str, ClassMethods],
     heading_level: int = 2,
 ) -> tuple[list[str], bool]:
     """Render a class and return (lines, has_content)."""
@@ -679,22 +800,37 @@ def render_class(
     for g in groups:
         if isinstance(g, ast.ClassDef):
             sub_lines, _ = render_class(
-                g, class_index, current_page, stub_module_index, heading_level + 1
+                g,
+                class_index,
+                current_page,
+                stub_module_index,
+                f"{qualified_class}.{g.name}",
+                class_methods,
+                heading_level + 1,
             )
             nested_class_lines.extend(sub_lines)
-        else:
-            node = g[0]
-            if node.name in SKIP_NAMES or is_setter(node):
+
+    methods = class_methods.get(qualified_class)
+    if methods is None:
+        methods = {
+            name: (group, qualified_class)
+            for name, group in _direct_method_groups(class_node).items()
+        }
+    for group, owner in methods.values():
+        node = group[0]
+        if node.name in SKIP_NAMES or is_setter(node):
+            continue
+        if node.name == "__init__":
+            if any(
+                get_docstring(ov) and NOT_INSTANTIABLE.search(get_docstring(ov))
+                for ov in group
+            ):
                 continue
-            if node.name == "__init__":
-                if any(
-                    get_docstring(ov) and NOT_INSTANTIABLE.search(get_docstring(ov))
-                    for ov in g
-                ):
-                    continue
-            sig = _sig_cell(g, class_index, current_page)
-            desc = _desc_cell(g)
-            method_rows.append((sig, desc))
+        sig = _sig_cell(group, class_index, current_page)
+        if owner != qualified_class:
+            sig += f" *(inherited from {_type_link(owner, class_index, current_page)})*"
+        desc = _desc_cell(group)
+        method_rows.append((sig, desc))
 
     has_content = bool(raw_doc or bases or method_rows or nested_class_lines)
     if not has_content:
@@ -744,8 +880,10 @@ def render_class(
 def generate_module_md(
     stub_path: Path,
     module_name: str,
+    current_stub_module: str,
     class_index: ClassIndex,
     stub_module_index: dict[str, str],
+    class_methods: dict[str, ClassMethods],
 ) -> str:
     tree = _parse_stub(stub_path)
     if tree is None:
@@ -791,7 +929,12 @@ def generate_module_md(
     for item in tree.body:
         if isinstance(item, ast.ClassDef):
             class_lines, ok = render_class(
-                item, class_index, current_page, stub_module_index
+                item,
+                class_index,
+                current_page,
+                stub_module_index,
+                f"{current_stub_module}.{item.name}",
+                class_methods,
             )
             if ok:
                 lines.extend(class_lines)
@@ -828,7 +971,14 @@ def main() -> None:
     args.output.mkdir(parents=True, exist_ok=True)
 
     class_index = build_class_index(args.stubs)
+    class_definitions = build_class_definition_index(args.stubs)
     stub_module_index = _build_stub_module_index()
+    class_methods: dict[str, ClassMethods] = {}
+    mro_cache: dict[str, list[str]] = {}
+    for qualified_class in class_definitions:
+        _collect_class_methods(
+            qualified_class, class_definitions, class_methods, mro_cache
+        )
 
     generated: list[tuple[str, str, str]] = []
     for rel_stub, module_name, description in MODULES:
@@ -838,7 +988,14 @@ def main() -> None:
             continue
         out_file = args.output / f"{slug(module_name)}.md"
         out_file.write_text(
-            generate_module_md(stub_path, module_name, class_index, stub_module_index)
+            generate_module_md(
+                stub_path,
+                module_name,
+                _stub_module_name(rel_stub),
+                class_index,
+                stub_module_index,
+                class_methods,
+            )
         )
         generated.append((module_name, slug(module_name), description))
         print(f"  {out_file.name}")

From fd899d404466c229969da726e922a9e35b94b193 Mon Sep 17 00:00:00 2001
From: Paul Sardin 
Date: Wed, 29 Jul 2026 10:53:45 +0200
Subject: [PATCH 7/7] [manipulation] Synchronize path validation factories

---
 src/pyhpp/manipulation/problem.cc           | 30 ++++++++++++++++-----
 src/pyhpp/manipulation/problem.hh           |  3 +++
 tests/integration/construction-set-m-rrt.py |  2 ++
 tests/integration/ur3-spheres-spf.py        |  1 +
 tests/integration/ur3-spheres.py            |  1 +
 5 files changed, 31 insertions(+), 6 deletions(-)

diff --git a/src/pyhpp/manipulation/problem.cc b/src/pyhpp/manipulation/problem.cc
index fc3924e5..d818f291 100644
--- a/src/pyhpp/manipulation/problem.cc
+++ b/src/pyhpp/manipulation/problem.cc
@@ -154,8 +154,6 @@ pyhpp::core::PyWPathValidationPtr_t Problem::pyPathValidation() const {
 
 void Problem::pyPathValidation(
     const pyhpp::core::PyWPathValidationPtr_t& pathValidation) {
-  if (!pathValidation || !pathValidation->factory)
-    throw std::invalid_argument("Path validation has no factory.");
   hpp::manipulation::ProblemPtr_t problem = asManipulationProblem();
   auto obstacleUser = HPP_DYNAMIC_PTR_CAST(hpp::core::ObstacleUserInterface,
                                            pathValidation->obj);
@@ -165,8 +163,19 @@ void Problem::pyPathValidation(
 
   problem->pathValidation(pathValidation->obj);
   pathValidation_ = pathValidation;
-  problem->setPathValidationFactory(pathValidation->factory,
-                                    pathValidation->tolerance);
+}
+
+pyhpp::core::PyWPathValidationPtr_t Problem::pyPathValidationFactory() const {
+  return std::make_shared(
+      asManipulationProblem()->pathValidationFactory());
+}
+
+void Problem::pyPathValidationFactory(
+    const pyhpp::core::PyWPathValidationPtr_t& pathValidation) {
+  if (!pathValidation || !pathValidation->factory)
+    throw std::invalid_argument("Path validation has no factory.");
+  asManipulationProblem()->setPathValidationFactory(pathValidation->factory,
+                                                    pathValidation->tolerance);
 }
 
 // PathValidationPtr_t Problem::pathValidation() const {
@@ -245,8 +254,17 @@ void exposeProblem() {
            static_cast(
                &Problem::pyPathValidation),
-           (arg("pathValidation")),
-           "Set the path validation object and edge-validation factory.")
+           (arg("pathValidation")), "Set the path validation object.")
+      .def(
+          "pathValidationFactory",
+          static_cast(
+              &Problem::pyPathValidationFactory),
+          "Create a path validation using the edge-validation factory.")
+      .def("pathValidationFactory",
+           static_cast(
+               &Problem::pyPathValidationFactory),
+           (arg("pathValidation")), "Set the edge-validation factory.")
       // .PYHPP_DEFINE_GETTER_SETTER_CONST_REF(Problem, pathValidation,
       // PathValidationPtr_t) .PYHPP_DEFINE_METHOD(Problem,
       // manipulationSteeringMethod) .PYHPP_DEFINE_METHOD(Problem,
diff --git a/src/pyhpp/manipulation/problem.hh b/src/pyhpp/manipulation/problem.hh
index e7943809..db2775e1 100644
--- a/src/pyhpp/manipulation/problem.hh
+++ b/src/pyhpp/manipulation/problem.hh
@@ -65,6 +65,9 @@ struct Problem : public pyhpp::core::Problem {
   pyhpp::core::PyWPathValidationPtr_t pyPathValidation() const;
   void pyPathValidation(
       const pyhpp::core::PyWPathValidationPtr_t& pathValidation);
+  pyhpp::core::PyWPathValidationPtr_t pyPathValidationFactory() const;
+  void pyPathValidationFactory(
+      const pyhpp::core::PyWPathValidationPtr_t& pathValidation);
   // PathValidationPtr_t pathValidation() const;
   // void pathValidation (const PathValidationPtr_t &pathValidation);
   // SteeringMethodPtr_t manipulationSteeringMethod() const;
diff --git a/tests/integration/construction-set-m-rrt.py b/tests/integration/construction-set-m-rrt.py
index e3c64346..91629a60 100644
--- a/tests/integration/construction-set-m-rrt.py
+++ b/tests/integration/construction-set-m-rrt.py
@@ -408,6 +408,7 @@ def forbidExcept(g: str, h: T.List[str]) -> T.List[Rule]:
 
     problem.steeringMethod(Straight(problem))
     problem.pathValidation(Progressive(robot, 0.02))
+    problem.pathValidationFactory(Progressive(robot, 0.02))
     problem.pathProjector(
         ProgressiveProjector(problem.distance(), problem.steeringMethod(), 0.05)
     )
@@ -447,6 +448,7 @@ def forbidExcept(g: str, h: T.List[str]) -> T.List[Rule]:
 
     problem.steeringMethod(Straight(problem))
     problem.pathValidation(Progressive(robot, 0.02))
+    problem.pathValidationFactory(Progressive(robot, 0.02))
     problem.pathProjector(
         ProgressiveProjector(problem.distance(), problem.steeringMethod(), 0.05)
     )
diff --git a/tests/integration/ur3-spheres-spf.py b/tests/integration/ur3-spheres-spf.py
index d49818d9..20b7619d 100644
--- a/tests/integration/ur3-spheres-spf.py
+++ b/tests/integration/ur3-spheres-spf.py
@@ -281,6 +281,7 @@
 
 problem.steeringMethod(Straight(problem))
 problem.pathValidation(Dichotomy(robot, 0))
+problem.pathValidationFactory(Dichotomy(robot, 0))
 
 # need to set path projector due to implicit constraints added above
 problem.pathProjector(
diff --git a/tests/integration/ur3-spheres.py b/tests/integration/ur3-spheres.py
index 0b7c3bdf..5db12b04 100644
--- a/tests/integration/ur3-spheres.py
+++ b/tests/integration/ur3-spheres.py
@@ -260,6 +260,7 @@
 
 problem.steeringMethod(Straight(problem))
 problem.pathValidation(Dichotomy(robot, 0))
+problem.pathValidationFactory(Dichotomy(robot, 0))
 problem.pathProjector(
     ProgressiveProjector(problem.distance(), problem.steeringMethod(), 0.01)
 )