feat(kcsolve): pybind11 bindings and Python solver support

Add the kcsolve pybind11 module exposing the KCSolve C++ API to Python:

- PyIKCSolver trampoline enabling Python IKCSolver subclasses
- Bindings for all 5 enums, 10 structs, IKCSolver, and OndselAdapter
- Module functions wrapping SolverRegistry (available, load, joints_for,
  set_default, get_default, register_solver)
- PySolverHolder class forwarding virtual calls with GIL acquisition
- register_solver() for runtime Python solver registration

IKCSolver constructor moved from protected to public for pybind11
trampoline access (class remains abstract via 3 pure virtuals).

Includes 16 Python tests covering module import, type bindings, enum
values, registry functions, Python solver subclassing, and full
register/load/solve round-trip.

Closes #288
This commit is contained in:
forbes
2026-02-19 17:20:23 -06:00
parent 934cdf5767
commit 57d50f9f20
8 changed files with 762 additions and 1 deletions

View File

@@ -0,0 +1,237 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""Unit tests for the kcsolve pybind11 module."""
import unittest
class TestKCSolveImport(unittest.TestCase):
"""Verify that the kcsolve module loads and exposes expected symbols."""
def test_import(self):
import kcsolve
for sym in (
"IKCSolver",
"OndselAdapter",
"Transform",
"Part",
"Constraint",
"SolveContext",
"SolveResult",
"BaseJointKind",
"SolveStatus",
"available",
"load",
"register_solver",
):
self.assertTrue(hasattr(kcsolve, sym), f"missing symbol: {sym}")
def test_api_version(self):
import kcsolve
self.assertEqual(kcsolve.API_VERSION_MAJOR, 1)
class TestKCSolveTypes(unittest.TestCase):
"""Verify struct/enum bindings behave correctly."""
def test_transform_identity(self):
import kcsolve
t = kcsolve.Transform.identity()
self.assertEqual(list(t.position), [0.0, 0.0, 0.0])
self.assertEqual(list(t.quaternion), [1.0, 0.0, 0.0, 0.0]) # w,x,y,z
def test_part_defaults(self):
import kcsolve
p = kcsolve.Part()
self.assertEqual(p.id, "")
self.assertAlmostEqual(p.mass, 1.0)
self.assertFalse(p.grounded)
def test_solve_context_construction(self):
import kcsolve
ctx = kcsolve.SolveContext()
self.assertEqual(len(ctx.parts), 0)
self.assertEqual(len(ctx.constraints), 0)
p = kcsolve.Part()
p.id = "part1"
# pybind11 def_readwrite on std::vector returns a copy,
# so we must assign the whole list back.
ctx.parts = [p]
self.assertEqual(len(ctx.parts), 1)
self.assertEqual(ctx.parts[0].id, "part1")
def test_enum_values(self):
import kcsolve
self.assertEqual(int(kcsolve.SolveStatus.Success), 0)
# BaseJointKind.Fixed should exist
self.assertIsNotNone(kcsolve.BaseJointKind.Fixed)
# DiagnosticKind should exist
self.assertIsNotNone(kcsolve.DiagnosticKind.Redundant)
def test_constraint_fields(self):
import kcsolve
c = kcsolve.Constraint()
c.id = "Joint001"
c.part_i = "part1"
c.part_j = "part2"
c.type = kcsolve.BaseJointKind.Fixed
self.assertEqual(c.id, "Joint001")
self.assertEqual(c.type, kcsolve.BaseJointKind.Fixed)
def test_solve_result_fields(self):
import kcsolve
r = kcsolve.SolveResult()
self.assertEqual(r.status, kcsolve.SolveStatus.Success)
self.assertEqual(r.dof, -1)
self.assertEqual(len(r.placements), 0)
class TestKCSolveRegistry(unittest.TestCase):
"""Verify SolverRegistry wrapper functions."""
def test_available_returns_list(self):
import kcsolve
result = kcsolve.available()
self.assertIsInstance(result, list)
def test_load_ondsel(self):
import kcsolve
solver = kcsolve.load("ondsel")
# Ondsel should be registered by FreeCAD init
if solver is not None:
self.assertIn("Ondsel", solver.name())
def test_load_unknown_returns_none(self):
import kcsolve
solver = kcsolve.load("nonexistent_solver_xyz")
self.assertIsNone(solver)
def test_get_set_default(self):
import kcsolve
original = kcsolve.get_default()
# Setting unknown solver should return False
self.assertFalse(kcsolve.set_default("nonexistent_solver_xyz"))
# Default should be unchanged
self.assertEqual(kcsolve.get_default(), original)
class TestPySolver(unittest.TestCase):
"""Verify Python IKCSolver subclassing and registration."""
def _make_solver_class(self):
import kcsolve
class _DummySolver(kcsolve.IKCSolver):
def name(self):
return "DummyPySolver"
def supported_joints(self):
return [
kcsolve.BaseJointKind.Fixed,
kcsolve.BaseJointKind.Revolute,
]
def solve(self, ctx):
r = kcsolve.SolveResult()
r.status = kcsolve.SolveStatus.Success
parts = ctx.parts # copy from C++ vector
r.dof = len(parts) * 6
placements = []
for p in parts:
pr = kcsolve.SolveResult.PartResult()
pr.id = p.id
pr.placement = p.placement
placements.append(pr)
r.placements = placements
return r
return _DummySolver
def test_instantiate_python_solver(self):
cls = self._make_solver_class()
solver = cls()
self.assertEqual(solver.name(), "DummyPySolver")
self.assertEqual(len(solver.supported_joints()), 2)
def test_python_solver_solve(self):
import kcsolve
cls = self._make_solver_class()
solver = cls()
ctx = kcsolve.SolveContext()
p = kcsolve.Part()
p.id = "box1"
p.grounded = True
ctx.parts = [p]
result = solver.solve(ctx)
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
self.assertEqual(result.dof, 6)
self.assertEqual(len(result.placements), 1)
self.assertEqual(result.placements[0].id, "box1")
def test_register_and_roundtrip(self):
import kcsolve
cls = self._make_solver_class()
# Use a unique name to avoid collision across test runs
name = "test_dummy_roundtrip"
kcsolve.register_solver(name, cls)
self.assertIn(name, kcsolve.available())
loaded = kcsolve.load(name)
self.assertIsNotNone(loaded)
self.assertEqual(loaded.name(), "DummyPySolver")
ctx = kcsolve.SolveContext()
result = loaded.solve(ctx)
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
def test_default_virtuals(self):
"""Default implementations of optional virtuals should not crash."""
import kcsolve
cls = self._make_solver_class()
solver = cls()
self.assertTrue(solver.is_deterministic())
self.assertFalse(solver.supports_bundle_fixed())
ctx = kcsolve.SolveContext()
diags = solver.diagnose(ctx)
self.assertEqual(len(diags), 0)

View File

@@ -58,6 +58,7 @@ SET(AssemblyTests_SRCS
AssemblyTests/TestCore.py
AssemblyTests/TestCommandInsertLink.py
AssemblyTests/TestSolverIntegration.py
AssemblyTests/TestKCSolvePy.py
AssemblyTests/mocks/__init__.py
AssemblyTests/mocks/MockGui.py
)

View File

@@ -40,3 +40,7 @@ endif()
SET_BIN_DIR(KCSolve KCSolve /Mod/Assembly)
INSTALL(TARGETS KCSolve DESTINATION ${CMAKE_INSTALL_LIBDIR})
if(FREECAD_USE_PYBIND11)
add_subdirectory(bindings)
endif()

View File

@@ -172,9 +172,11 @@ public:
return false;
}
protected:
// Public default constructor for pybind11 trampoline support.
// The class remains abstract (3 pure virtuals prevent direct instantiation).
IKCSolver() = default;
private:
// Non-copyable, non-movable (polymorphic base class)
IKCSolver(const IKCSolver&) = delete;
IKCSolver& operator=(const IKCSolver&) = delete;

View File

@@ -0,0 +1,31 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
set(KCSolvePy_SRCS
PyIKCSolver.h
kcsolve_py.cpp
)
add_library(kcsolve_py SHARED ${KCSolvePy_SRCS})
target_include_directories(kcsolve_py
PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_BINARY_DIR}/src
${pybind11_INCLUDE_DIR}
)
target_link_libraries(kcsolve_py
PRIVATE
pybind11::module
Python3::Python
KCSolve
)
if(FREECAD_WARN_ERROR)
target_compile_warn_error(kcsolve_py)
endif()
SET_BIN_DIR(kcsolve_py kcsolve /Mod/Assembly)
SET_PYTHON_PREFIX_SUFFIX(kcsolve_py)
INSTALL(TARGETS kcsolve_py DESTINATION ${CMAKE_INSTALL_LIBDIR})

View File

@@ -0,0 +1,121 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSOLVE_PYIKCSOLVER_H
#define KCSOLVE_PYIKCSOLVER_H
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <Mod/Assembly/Solver/IKCSolver.h>
namespace KCSolve
{
/// pybind11 trampoline class for IKCSolver.
/// Enables Python subclasses that override virtual methods.
class PyIKCSolver : public IKCSolver
{
public:
using IKCSolver::IKCSolver;
// ── Pure virtuals ──────────────────────────────────────────────
std::string name() const override
{
PYBIND11_OVERRIDE_PURE(std::string, IKCSolver, name);
}
std::vector<BaseJointKind> supported_joints() const override
{
PYBIND11_OVERRIDE_PURE(std::vector<BaseJointKind>, IKCSolver, supported_joints);
}
SolveResult solve(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE_PURE(SolveResult, IKCSolver, solve, ctx);
}
// ── Virtuals with defaults ─────────────────────────────────────
SolveResult update(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, update, ctx);
}
SolveResult pre_drag(const SolveContext& ctx,
const std::vector<std::string>& drag_parts) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, pre_drag, ctx, drag_parts);
}
SolveResult drag_step(
const std::vector<SolveResult::PartResult>& drag_placements) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, drag_step, drag_placements);
}
void post_drag() override
{
PYBIND11_OVERRIDE(void, IKCSolver, post_drag);
}
SolveResult run_kinematic(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, run_kinematic, ctx);
}
std::size_t num_frames() const override
{
PYBIND11_OVERRIDE(std::size_t, IKCSolver, num_frames);
}
SolveResult update_for_frame(std::size_t index) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, update_for_frame, index);
}
std::vector<ConstraintDiagnostic> diagnose(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE(std::vector<ConstraintDiagnostic>, IKCSolver, diagnose, ctx);
}
bool is_deterministic() const override
{
PYBIND11_OVERRIDE(bool, IKCSolver, is_deterministic);
}
void export_native(const std::string& path) override
{
PYBIND11_OVERRIDE(void, IKCSolver, export_native, path);
}
bool supports_bundle_fixed() const override
{
PYBIND11_OVERRIDE(bool, IKCSolver, supports_bundle_fixed);
}
};
} // namespace KCSolve
#endif // KCSOLVE_PYIKCSOLVER_H

View File

@@ -0,0 +1,359 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <Mod/Assembly/Solver/IKCSolver.h>
#include <Mod/Assembly/Solver/OndselAdapter.h>
#include <Mod/Assembly/Solver/SolverRegistry.h>
#include <Mod/Assembly/Solver/Types.h>
#include "PyIKCSolver.h"
#include <memory>
#include <string>
namespace py = pybind11;
using namespace KCSolve;
// ── PySolverHolder ─────────────────────────────────────────────────
//
// Wraps a Python IKCSolver subclass instance so it can live inside a
// std::unique_ptr<IKCSolver> returned by SolverRegistry::get().
// Prevents Python GC by holding a py::object reference and acquires
// the GIL before every forwarded call.
class PySolverHolder : public IKCSolver
{
public:
explicit PySolverHolder(py::object obj)
: obj_(std::move(obj))
{
solver_ = obj_.cast<IKCSolver*>();
}
std::string name() const override
{
py::gil_scoped_acquire gil;
return solver_->name();
}
std::vector<BaseJointKind> supported_joints() const override
{
py::gil_scoped_acquire gil;
return solver_->supported_joints();
}
SolveResult solve(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->solve(ctx);
}
SolveResult update(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->update(ctx);
}
SolveResult pre_drag(const SolveContext& ctx,
const std::vector<std::string>& drag_parts) override
{
py::gil_scoped_acquire gil;
return solver_->pre_drag(ctx, drag_parts);
}
SolveResult drag_step(
const std::vector<SolveResult::PartResult>& drag_placements) override
{
py::gil_scoped_acquire gil;
return solver_->drag_step(drag_placements);
}
void post_drag() override
{
py::gil_scoped_acquire gil;
solver_->post_drag();
}
SolveResult run_kinematic(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->run_kinematic(ctx);
}
std::size_t num_frames() const override
{
py::gil_scoped_acquire gil;
return solver_->num_frames();
}
SolveResult update_for_frame(std::size_t index) override
{
py::gil_scoped_acquire gil;
return solver_->update_for_frame(index);
}
std::vector<ConstraintDiagnostic> diagnose(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->diagnose(ctx);
}
bool is_deterministic() const override
{
py::gil_scoped_acquire gil;
return solver_->is_deterministic();
}
void export_native(const std::string& path) override
{
py::gil_scoped_acquire gil;
solver_->export_native(path);
}
bool supports_bundle_fixed() const override
{
py::gil_scoped_acquire gil;
return solver_->supports_bundle_fixed();
}
private:
py::object obj_; // prevents Python GC
IKCSolver* solver_; // raw pointer into the trampoline inside obj_
};
// ── Module definition ──────────────────────────────────────────────
PYBIND11_MODULE(kcsolve, m)
{
m.doc() = "KCSolve — pluggable assembly constraint solver API";
m.attr("API_VERSION_MAJOR") = API_VERSION_MAJOR;
// ── Enums ──────────────────────────────────────────────────────
py::enum_<BaseJointKind>(m, "BaseJointKind")
.value("Coincident", BaseJointKind::Coincident)
.value("PointOnLine", BaseJointKind::PointOnLine)
.value("PointInPlane", BaseJointKind::PointInPlane)
.value("Concentric", BaseJointKind::Concentric)
.value("Tangent", BaseJointKind::Tangent)
.value("Planar", BaseJointKind::Planar)
.value("LineInPlane", BaseJointKind::LineInPlane)
.value("Parallel", BaseJointKind::Parallel)
.value("Perpendicular", BaseJointKind::Perpendicular)
.value("Angle", BaseJointKind::Angle)
.value("Fixed", BaseJointKind::Fixed)
.value("Revolute", BaseJointKind::Revolute)
.value("Cylindrical", BaseJointKind::Cylindrical)
.value("Slider", BaseJointKind::Slider)
.value("Ball", BaseJointKind::Ball)
.value("Screw", BaseJointKind::Screw)
.value("Universal", BaseJointKind::Universal)
.value("Gear", BaseJointKind::Gear)
.value("RackPinion", BaseJointKind::RackPinion)
.value("Cam", BaseJointKind::Cam)
.value("Slot", BaseJointKind::Slot)
.value("DistancePointPoint", BaseJointKind::DistancePointPoint)
.value("DistanceCylSph", BaseJointKind::DistanceCylSph)
.value("Custom", BaseJointKind::Custom);
py::enum_<SolveStatus>(m, "SolveStatus")
.value("Success", SolveStatus::Success)
.value("Failed", SolveStatus::Failed)
.value("InvalidFlip", SolveStatus::InvalidFlip)
.value("NoGroundedParts", SolveStatus::NoGroundedParts);
py::enum_<ConstraintDiagnostic::Kind>(m, "DiagnosticKind")
.value("Redundant", ConstraintDiagnostic::Kind::Redundant)
.value("Conflicting", ConstraintDiagnostic::Kind::Conflicting)
.value("PartiallyRedundant", ConstraintDiagnostic::Kind::PartiallyRedundant)
.value("Malformed", ConstraintDiagnostic::Kind::Malformed);
py::enum_<MotionDef::Kind>(m, "MotionKind")
.value("Rotational", MotionDef::Kind::Rotational)
.value("Translational", MotionDef::Kind::Translational)
.value("General", MotionDef::Kind::General);
py::enum_<Constraint::Limit::Kind>(m, "LimitKind")
.value("TranslationMin", Constraint::Limit::Kind::TranslationMin)
.value("TranslationMax", Constraint::Limit::Kind::TranslationMax)
.value("RotationMin", Constraint::Limit::Kind::RotationMin)
.value("RotationMax", Constraint::Limit::Kind::RotationMax);
// ── Struct bindings ────────────────────────────────────────────
py::class_<Transform>(m, "Transform")
.def(py::init<>())
.def_readwrite("position", &Transform::position)
.def_readwrite("quaternion", &Transform::quaternion)
.def_static("identity", &Transform::identity)
.def("__repr__", [](const Transform& t) {
return "<kcsolve.Transform pos=["
+ std::to_string(t.position[0]) + ", "
+ std::to_string(t.position[1]) + ", "
+ std::to_string(t.position[2]) + "]>";
});
py::class_<Part>(m, "Part")
.def(py::init<>())
.def_readwrite("id", &Part::id)
.def_readwrite("placement", &Part::placement)
.def_readwrite("mass", &Part::mass)
.def_readwrite("grounded", &Part::grounded);
auto constraint_class = py::class_<Constraint>(m, "Constraint");
py::class_<Constraint::Limit>(constraint_class, "Limit")
.def(py::init<>())
.def_readwrite("kind", &Constraint::Limit::kind)
.def_readwrite("value", &Constraint::Limit::value)
.def_readwrite("tolerance", &Constraint::Limit::tolerance);
constraint_class
.def(py::init<>())
.def_readwrite("id", &Constraint::id)
.def_readwrite("part_i", &Constraint::part_i)
.def_readwrite("marker_i", &Constraint::marker_i)
.def_readwrite("part_j", &Constraint::part_j)
.def_readwrite("marker_j", &Constraint::marker_j)
.def_readwrite("type", &Constraint::type)
.def_readwrite("params", &Constraint::params)
.def_readwrite("limits", &Constraint::limits)
.def_readwrite("activated", &Constraint::activated);
py::class_<MotionDef>(m, "MotionDef")
.def(py::init<>())
.def_readwrite("kind", &MotionDef::kind)
.def_readwrite("joint_id", &MotionDef::joint_id)
.def_readwrite("marker_i", &MotionDef::marker_i)
.def_readwrite("marker_j", &MotionDef::marker_j)
.def_readwrite("rotation_expr", &MotionDef::rotation_expr)
.def_readwrite("translation_expr", &MotionDef::translation_expr);
py::class_<SimulationParams>(m, "SimulationParams")
.def(py::init<>())
.def_readwrite("t_start", &SimulationParams::t_start)
.def_readwrite("t_end", &SimulationParams::t_end)
.def_readwrite("h_out", &SimulationParams::h_out)
.def_readwrite("h_min", &SimulationParams::h_min)
.def_readwrite("h_max", &SimulationParams::h_max)
.def_readwrite("error_tol", &SimulationParams::error_tol);
py::class_<SolveContext>(m, "SolveContext")
.def(py::init<>())
.def_readwrite("parts", &SolveContext::parts)
.def_readwrite("constraints", &SolveContext::constraints)
.def_readwrite("motions", &SolveContext::motions)
.def_readwrite("simulation", &SolveContext::simulation)
.def_readwrite("bundle_fixed", &SolveContext::bundle_fixed);
py::class_<ConstraintDiagnostic>(m, "ConstraintDiagnostic")
.def(py::init<>())
.def_readwrite("constraint_id", &ConstraintDiagnostic::constraint_id)
.def_readwrite("kind", &ConstraintDiagnostic::kind)
.def_readwrite("detail", &ConstraintDiagnostic::detail);
auto result_class = py::class_<SolveResult>(m, "SolveResult");
py::class_<SolveResult::PartResult>(result_class, "PartResult")
.def(py::init<>())
.def_readwrite("id", &SolveResult::PartResult::id)
.def_readwrite("placement", &SolveResult::PartResult::placement);
result_class
.def(py::init<>())
.def_readwrite("status", &SolveResult::status)
.def_readwrite("placements", &SolveResult::placements)
.def_readwrite("dof", &SolveResult::dof)
.def_readwrite("diagnostics", &SolveResult::diagnostics)
.def_readwrite("num_frames", &SolveResult::num_frames);
// ── IKCSolver (with trampoline for Python subclassing) ─────────
py::class_<IKCSolver, PyIKCSolver>(m, "IKCSolver")
.def(py::init<>())
.def("name", &IKCSolver::name)
.def("supported_joints", &IKCSolver::supported_joints)
.def("solve", &IKCSolver::solve, py::arg("ctx"))
.def("update", &IKCSolver::update, py::arg("ctx"))
.def("pre_drag", &IKCSolver::pre_drag,
py::arg("ctx"), py::arg("drag_parts"))
.def("drag_step", &IKCSolver::drag_step,
py::arg("drag_placements"))
.def("post_drag", &IKCSolver::post_drag)
.def("run_kinematic", &IKCSolver::run_kinematic, py::arg("ctx"))
.def("num_frames", &IKCSolver::num_frames)
.def("update_for_frame", &IKCSolver::update_for_frame,
py::arg("index"))
.def("diagnose", &IKCSolver::diagnose, py::arg("ctx"))
.def("is_deterministic", &IKCSolver::is_deterministic)
.def("export_native", &IKCSolver::export_native, py::arg("path"))
.def("supports_bundle_fixed", &IKCSolver::supports_bundle_fixed);
// ── OndselAdapter ──────────────────────────────────────────────
py::class_<OndselAdapter, IKCSolver>(m, "OndselAdapter")
.def(py::init<>());
// ── Module-level functions (SolverRegistry wrapper) ────────────
m.def("available", []() {
return SolverRegistry::instance().available();
}, "Return names of all registered solvers.");
m.def("load", [](const std::string& name) {
return SolverRegistry::instance().get(name);
}, py::arg("name") = "",
"Create an instance of the named solver (default if empty).\n"
"Returns None if the solver is not found.");
m.def("joints_for", [](const std::string& name) {
return SolverRegistry::instance().joints_for(name);
}, py::arg("name"),
"Query supported joint types for the named solver.");
m.def("set_default", [](const std::string& name) {
return SolverRegistry::instance().set_default(name);
}, py::arg("name"),
"Set the default solver name. Returns True if the name is registered.");
m.def("get_default", []() {
return SolverRegistry::instance().get_default();
}, "Get the current default solver name.");
m.def("register_solver", [](const std::string& name, py::object py_solver_class) {
auto cls = std::make_shared<py::object>(std::move(py_solver_class));
CreateSolverFn factory = [cls]() -> std::unique_ptr<IKCSolver> {
py::gil_scoped_acquire gil;
py::object instance = (*cls)();
return std::make_unique<PySolverHolder>(std::move(instance));
};
return SolverRegistry::instance().register_solver(name, std::move(factory));
}, py::arg("name"), py::arg("solver_class"),
"Register a Python solver class with the SolverRegistry.\n"
"solver_class must be a callable that returns an IKCSolver subclass.");
}

View File

@@ -24,6 +24,12 @@
import TestApp
from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink
from AssemblyTests.TestCore import TestCore
from AssemblyTests.TestKCSolvePy import (
TestKCSolveImport, # noqa: F401
TestKCSolveRegistry, # noqa: F401
TestKCSolveTypes, # noqa: F401
TestPySolver, # noqa: F401
)
from AssemblyTests.TestSolverIntegration import TestSolverIntegration
# Use the modules so that code checkers don't complain (flake8)