Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ compatibility remains on the active DART 6 LTS branch._

#### Python Bindings

- Added nanobind 3 compatibility for dartpy source and isolated wheel builds
while retaining nanobind 2 as a tested development configuration. (Follow-up
to [#3448](https://github.com/dartsim/dart/pull/3448))
- Expanded dartpy bindings, documentation, autodoc stub handling, and examples
around the nanobind surface, while keeping opt-in legacy compatibility warnings
where migration still needs a bridge.
Expand Down
2 changes: 1 addition & 1 deletion docs/onboarding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ DART addresses the need for:
| ----------------------- | ------------------ | ------------------------- |
| **Core Language** | C++23 | Main implementation |
| **Build System** | CMake 4.2.3+ | Cross-platform builds |
| **Python Bindings** | nanobind 2.9.x | Python API |
| **Python Bindings** | nanobind 2.9.2–3.x | Python API |
| **Linear Algebra** | Eigen 3.4.0+ | Math operations |
| **Collision Detection** | FCL 0.7.0+ | Primary collision backend |
| **3D Rendering** | Filament 1.71.3 | Visualization |
Expand Down
9 changes: 9 additions & 0 deletions docs/onboarding/python-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@

**Result**: Simpler, faster builds with better developer experience

### nanobind compatibility lanes

Dartpy supports nanobind 2.9.2 through 3.x. The default Pixi environment stays
on nanobind 2.x so normal development and `pixi run test-all` preserve the
older supported major. PEP 517 isolated wheel builds resolve the newest
supported major from `pyproject.toml`, so `pixi run -e py314-wheel wheel-build`
and the hosted wheel matrix exercise nanobind 3.x. Changes to dartpy casters or
trampolines should validate both paths.

## Architecture

### Module Structure
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# All rights reserved.

[build-system]
requires = ["scikit-build-core>=0.10", "nanobind>=2.9.2,<3", "requests"]
requires = ["scikit-build-core>=0.10", "nanobind>=2.9.2,<4", "requests"]
build-backend = "scikit_build_core.build"

[project]
Expand Down
21 changes: 13 additions & 8 deletions python/dartpy/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,11 @@ if(NOT DEFINED PYTHON_SITE_PACKAGES)
endif()

set(_dartpy_nanobind_min_version 2.9.2)
set(_dartpy_nanobind_max_version 4.0.0)

# Locate nanobind (prefer the copy shipped with the active Python interpreter).
set(_dartpy_nanobind_found FALSE)
if(DART_USE_SYSTEM_NANOBIND)
find_package(nanobind ${_dartpy_nanobind_min_version} CONFIG REQUIRED)
set(_dartpy_nanobind_found TRUE)
find_package(nanobind CONFIG REQUIRED)
else()
if(NOT DEFINED nanobind_DIR)
execute_process(
Expand All @@ -81,13 +80,18 @@ else()
FORCE
)
endif()
find_package(nanobind ${_dartpy_nanobind_min_version} CONFIG REQUIRED)
set(_dartpy_nanobind_found TRUE)
find_package(nanobind CONFIG REQUIRED)
endif()

if(NOT _dartpy_nanobind_found)
message(WARNING "nanobind is required to build dartpy; skipping target.")
return()
if(
nanobind_VERSION VERSION_LESS _dartpy_nanobind_min_version
OR NOT nanobind_VERSION VERSION_LESS _dartpy_nanobind_max_version
)
message(
FATAL_ERROR
"dartpy requires nanobind >=${_dartpy_nanobind_min_version},"
"<${_dartpy_nanobind_max_version}; found ${nanobind_VERSION}."
)
endif()

if(NOT DART_IO_HAS_URDF)
Expand All @@ -103,6 +107,7 @@ set(
common/composite.hpp
common/module.hpp
common/logging.hpp
common/nanobind_compat.hpp
common/observer.hpp
common/profile.hpp
common/repr.hpp
Expand Down
86 changes: 86 additions & 0 deletions python/dartpy/common/nanobind_compat.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#pragma once

#include <nanobind/nanobind.h>

#include <typeinfo>

#include <cstdint>

namespace dart::python_nb {

// nanobind 3 widened caster flags and routes core calls through a domain
// context. Keep that version boundary isolated from DART's custom casters.
#if NB_VERSION_MAJOR >= 3
using NanobindCastFlags = std::uint32_t;
#else
using NanobindCastFlags = std::uint8_t;
#endif

inline const std::type_info* nanobindTypeInfo(nanobind::handle type) noexcept
{
if (!nanobind::type_check(type)) {
return nullptr;
}

return &nanobind::type_info(type);
}

inline bool nanobindTypeGet(
const std::type_info* type,
PyObject* object,
NanobindCastFlags flags,
nanobind::detail::cleanup_list* cleanup,
void** output) noexcept
{
#if NB_VERSION_MAJOR >= 3
return NB_CALL(nb_type_get)(
NB_CTX_C(cleanup), type, object, flags, cleanup, output);
#else
return nanobind::detail::nb_type_get(type, object, flags, cleanup, output);
#endif
}

inline nanobind::handle nanobindTypePut(
const std::type_info* type,
const std::type_info* dynamicType,
void* value,
nanobind::rv_policy policy,
nanobind::detail::cleanup_list* cleanup) noexcept
{
#if NB_VERSION_MAJOR >= 3
return nanobind::handle(NB_CALL(nb_type_put)(
NB_CTX_C(cleanup), type, dynamicType, value, policy, cleanup, nullptr));
#else
return nanobind::handle(
nanobind::detail::nb_type_put_p(
type, dynamicType, value, policy, cleanup, nullptr));
#endif
}

inline void nanobindKeepAlive(PyObject* nurse, PyObject* patient) noexcept
{
#if NB_VERSION_MAJOR >= 3
NB_CALL(keep_alive_py)(NB_CTX, nurse, patient);
#else
nanobind::detail::keep_alive(nurse, patient);
#endif
}

inline void nanobindKeepAlive(
PyObject* nurse, void* payload, void (*deleter)(void*) noexcept) noexcept
{
#if NB_VERSION_MAJOR >= 3
NB_CALL(keep_alive_ptr)(NB_CTX, nurse, payload, deleter);
#else
nanobind::detail::keep_alive(nurse, payload, deleter);
#endif
}

} // namespace dart::python_nb

// nanobind 3 derives the trampoline override-cache size automatically.
#if NB_VERSION_MAJOR >= 3
#define DARTPY_NB_TRAMPOLINE(base, size) NB_TRAMPOLINE(base)
#else
#define DARTPY_NB_TRAMPOLINE(base, size) NB_TRAMPOLINE(base, size)
#endif
10 changes: 3 additions & 7 deletions python/dartpy/common/polymorphic_caster.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,11 @@ class PolymorphicCasterRegistry
return raw;

PyTypeObject* type = Py_TYPE(source);
if (!nanobind::detail::nb_type_check(reinterpret_cast<PyObject*>(type)))
nanobind::handle typeHandle(reinterpret_cast<PyObject*>(type));
if (!nanobind::type_check(typeHandle))
return raw;

const std::type_info* info
= nanobind::detail::nb_type_info(reinterpret_cast<PyObject*>(type));
if (info == nullptr)
return raw;

return convert(static_cast<void*>(raw), *info);
return convert(static_cast<void*>(raw), nanobind::type_info(typeHandle));
}

private:
Expand Down
15 changes: 1 addition & 14 deletions python/dartpy/common/polymorphic_utils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,7 @@ namespace dart::python_nb {
template <typename Base>
inline Base* castHandleTo(nanobind::handle h)
{
PyObject* obj = h.ptr();
if (!obj)
return nullptr;

PyTypeObject* type = Py_TYPE(obj);
const std::type_info* info
= nanobind::detail::nb_type_info(reinterpret_cast<PyObject*>(type));
void* raw = nanobind::detail::nb_inst_ptr(obj);

if (info && hasPolymorphicCaster<Base>(*info)) {
return convertPolymorphicPointer<Base>(raw, *info);
}

return static_cast<Base*>(raw);
return nanobind::cast<Base*>(h);
}

template <typename Base>
Expand Down
29 changes: 20 additions & 9 deletions python/dartpy/common/type_casters.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include "common/nanobind_compat.hpp"
#include "common/polymorphic_caster.hpp"

#include <nanobind/nanobind.h>
Expand All @@ -23,7 +24,9 @@ struct polymorphic_type_caster : type_caster_base_tag {
using Cast = precise_cast_t<T>;

NB_INLINE bool from_python(
handle src, uint8_t flags, cleanup_list* cleanup) noexcept
handle src,
dart::python_nb::NanobindCastFlags flags,
cleanup_list* cleanup) noexcept
{
if (src.is_none()) {
raw_ = nullptr;
Expand All @@ -32,19 +35,27 @@ struct polymorphic_type_caster : type_caster_base_tag {
}

PyObject* obj = src.ptr();
const std::type_info* info = nanobind::detail::nb_type_info(
reinterpret_cast<PyObject*>(Py_TYPE(obj)));
const std::type_info* info = dart::python_nb::nanobindTypeInfo(
handle(reinterpret_cast<PyObject*>(Py_TYPE(obj))));

if (info != nullptr
&& dart::python_nb::hasPolymorphicCaster<Type>(*info)) {
if (!nb_type_get(info, obj, flags, cleanup,
reinterpret_cast<void**>(&raw_)))
if (!dart::python_nb::nanobindTypeGet(
info,
obj,
flags,
cleanup,
reinterpret_cast<void**>(&raw_)))
return false;
value_ = dart::python_nb::convertPolymorphicPointer<Type>(
static_cast<void*>(raw_), *info);
} else {
if (!nb_type_get(&typeid(Type), obj, flags, cleanup,
reinterpret_cast<void**>(&raw_)))
if (!dart::python_nb::nanobindTypeGet(
&typeid(Type),
obj,
flags,
cleanup,
reinterpret_cast<void**>(&raw_)))
return false;
value_ = dart::python_nb::adjustPolymorphicPointer<Type>(obj, raw_);
}
Expand Down Expand Up @@ -76,8 +87,8 @@ struct polymorphic_type_caster : type_caster_base_tag {
adjusted = dynamic_cast<void*>(ptr);
}
}
return nb_type_put_p(
&typeid(Type), actual_type, adjusted, policy, cleanup, nullptr);
return dart::python_nb::nanobindTypePut(
&typeid(Type), actual_type, adjusted, policy, cleanup);
} else if constexpr (std::is_lvalue_reference_v<T>) {
return from_cpp(&value, policy, cleanup);
} else {
Expand Down
4 changes: 2 additions & 2 deletions python/dartpy/dynamics/skeleton.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ auto create_pair(
delete static_cast<std::shared_ptr<dart::dynamics::Skeleton>*>(payload);
};
nb::object jointObj = nb::cast(jointHandle, nb::rv_policy::move);
nb::detail::keep_alive(
nanobindKeepAlive(
jointObj.ptr(),
new std::shared_ptr<dart::dynamics::Skeleton>(skeletonHandle),
cleanup);
nb::object bodyObj = nb::cast(bodyHandle, nb::rv_policy::move);
nb::detail::keep_alive(
nanobindKeepAlive(
bodyObj.ptr(),
new std::shared_ptr<dart::dynamics::Skeleton>(skeletonHandle),
cleanup);
Expand Down
4 changes: 2 additions & 2 deletions python/dartpy/gui/panel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -387,15 +387,15 @@ void defGuiPanels(nb::module_& m)
})
.def_prop_ro(
"selected_point",
[](const PanelContextView& self) {
[](const PanelContextView& self) -> nb::object {
if (!self.context().selectedPoint.has_value()) {
return nb::none();
}
return nb::cast(*self.context().selectedPoint);
})
.def_prop_ro(
"selected_normal",
[](const PanelContextView& self) {
[](const PanelContextView& self) -> nb::object {
if (!self.context().selectedNormal.has_value()) {
return nb::none();
}
Expand Down
3 changes: 2 additions & 1 deletion python/dartpy/optimizer/function.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "optimizer/function.hpp"

#include "common/eigen_utils.hpp"
#include "common/nanobind_compat.hpp"
#include "dart/common/diagnostics.hpp"
#include "dart/math/optimization/function.hpp"

Expand All @@ -17,7 +18,7 @@ namespace dart::python_nb {
class PyFunction : public dart::math::Function
{
public:
NB_TRAMPOLINE(dart::math::Function, 2);
DARTPY_NB_TRAMPOLINE(dart::math::Function, 2);

double eval(const Eigen::VectorXd& x) override
{
Expand Down
9 changes: 8 additions & 1 deletion python/dartpy/optimizer/solver.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "optimizer/solver.hpp"

#include "common/nanobind_compat.hpp"
#include "dart/common/diagnostics.hpp"
#include "dart/math/optimization/problem.hpp"
#include "dart/math/optimization/solver.hpp"
Expand All @@ -19,7 +20,7 @@ namespace dart::python_nb {
class PySolver : public dart::math::Solver
{
public:
NB_TRAMPOLINE(dart::math::Solver, 1);
DARTPY_NB_TRAMPOLINE(dart::math::Solver, 1);

bool solve() override
{
Expand All @@ -30,7 +31,13 @@ class PySolver : public dart::math::Solver
{
// Cache the Python string so the view stays valid after the override.
if (!mTypeCacheInitialized) {
#if NB_VERSION_MAJOR >= 3
constexpr std::uint64_t getTypeHash
= nanobind::detail::str_hash("getType");
nb::detail::ticket nb_ticket(nb_trampoline, "getType", getTypeHash, true);
#else
nb::detail::ticket nb_ticket(nb_trampoline, "getType", true);
#endif
mTypeCache
= nb::cast<std::string>(nb_trampoline.base().attr(nb_ticket.key)());
mTypeCacheInitialized = true;
Expand Down
3 changes: 2 additions & 1 deletion python/dartpy/simulation/module_detail.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
// clang-format on

#include "common/eigen_utils.hpp"
#include "common/nanobind_compat.hpp"
#include "common/repr.hpp"

#include <dart/simulation/body/collision_shape.hpp>
Expand Down Expand Up @@ -493,7 +494,7 @@ inline nb::list castJointsKeepingWorldAlive(
nb::list result;
for (auto& joint : joints) {
nb::object jointObject = nb::cast(std::move(joint), nb::rv_policy::move);
nb::detail::keep_alive(jointObject.ptr(), world.ptr());
nanobindKeepAlive(jointObject.ptr(), world.ptr());
result.append(jointObject);
}
return result;
Expand Down
11 changes: 11 additions & 0 deletions python/tests/unit/dynamics/test_polymorphic_casts.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,14 @@ def test_end_effector_casts_across_frame_and_jacobian_bases():

ik = end_effector.get_or_create_ik()
assert ik.is_active()


def test_body_node_handles_cast_in_dynamic_joint_constraints():
skeleton1, body1 = _make_body()
skeleton2, body2 = _make_body()

constraint = dart.BallJointConstraint(body1, body2, [0.0, 0.0, 0.0])

assert skeleton1.get_num_body_nodes() == 1
assert skeleton2.get_num_body_nodes() == 1
assert constraint.get_type() == dart.BallJointConstraint.get_static_type()
Loading