From 7ea0078ba39a8bdc412e3c0bd61f22ddc27f7168 Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 17:20:23 -0600 Subject: [PATCH] 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 --- .../Assembly/AssemblyTests/TestKCSolvePy.py | 237 ++++++++++++ src/Mod/Assembly/CMakeLists.txt | 1 + src/Mod/Assembly/Solver/CMakeLists.txt | 4 + src/Mod/Assembly/Solver/IKCSolver.h | 4 +- .../Assembly/Solver/bindings/CMakeLists.txt | 31 ++ .../Assembly/Solver/bindings/PyIKCSolver.h | 121 ++++++ .../Assembly/Solver/bindings/kcsolve_py.cpp | 359 ++++++++++++++++++ src/Mod/Assembly/TestAssemblyWorkbench.py | 6 + 8 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py create mode 100644 src/Mod/Assembly/Solver/bindings/CMakeLists.txt create mode 100644 src/Mod/Assembly/Solver/bindings/PyIKCSolver.h create mode 100644 src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp diff --git a/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py new file mode 100644 index 0000000000..6d8406eae8 --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Kindred Systems * +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +"""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) diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt index 0505b216fa..b8ceb8a5f5 100644 --- a/src/Mod/Assembly/CMakeLists.txt +++ b/src/Mod/Assembly/CMakeLists.txt @@ -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 ) diff --git a/src/Mod/Assembly/Solver/CMakeLists.txt b/src/Mod/Assembly/Solver/CMakeLists.txt index 206ff7d1f4..2228f79c23 100644 --- a/src/Mod/Assembly/Solver/CMakeLists.txt +++ b/src/Mod/Assembly/Solver/CMakeLists.txt @@ -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() diff --git a/src/Mod/Assembly/Solver/IKCSolver.h b/src/Mod/Assembly/Solver/IKCSolver.h index 47abc5af01..4a404d134b 100644 --- a/src/Mod/Assembly/Solver/IKCSolver.h +++ b/src/Mod/Assembly/Solver/IKCSolver.h @@ -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; diff --git a/src/Mod/Assembly/Solver/bindings/CMakeLists.txt b/src/Mod/Assembly/Solver/bindings/CMakeLists.txt new file mode 100644 index 0000000000..0cde656f9d --- /dev/null +++ b/src/Mod/Assembly/Solver/bindings/CMakeLists.txt @@ -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}) diff --git a/src/Mod/Assembly/Solver/bindings/PyIKCSolver.h b/src/Mod/Assembly/Solver/bindings/PyIKCSolver.h new file mode 100644 index 0000000000..7d8151f49f --- /dev/null +++ b/src/Mod/Assembly/Solver/bindings/PyIKCSolver.h @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * 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 * + * . * + * * + ***************************************************************************/ + +#ifndef KCSOLVE_PYIKCSOLVER_H +#define KCSOLVE_PYIKCSOLVER_H + +#include +#include + +#include + +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 supported_joints() const override + { + PYBIND11_OVERRIDE_PURE(std::vector, 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& drag_parts) override + { + PYBIND11_OVERRIDE(SolveResult, IKCSolver, pre_drag, ctx, drag_parts); + } + + SolveResult drag_step( + const std::vector& 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 diagnose(const SolveContext& ctx) override + { + PYBIND11_OVERRIDE(std::vector, 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 diff --git a/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp b/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp new file mode 100644 index 0000000000..ddc125928b --- /dev/null +++ b/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * 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 * + * . * + * * + ***************************************************************************/ + +#include +#include + +#include +#include +#include +#include + +#include "PyIKCSolver.h" + +#include +#include + +namespace py = pybind11; +using namespace KCSolve; + + +// ── PySolverHolder ───────────────────────────────────────────────── +// +// Wraps a Python IKCSolver subclass instance so it can live inside a +// std::unique_ptr 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(); + } + + std::string name() const override + { + py::gil_scoped_acquire gil; + return solver_->name(); + } + + std::vector 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& drag_parts) override + { + py::gil_scoped_acquire gil; + return solver_->pre_drag(ctx, drag_parts); + } + + SolveResult drag_step( + const std::vector& 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 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_(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_(m, "SolveStatus") + .value("Success", SolveStatus::Success) + .value("Failed", SolveStatus::Failed) + .value("InvalidFlip", SolveStatus::InvalidFlip) + .value("NoGroundedParts", SolveStatus::NoGroundedParts); + + py::enum_(m, "DiagnosticKind") + .value("Redundant", ConstraintDiagnostic::Kind::Redundant) + .value("Conflicting", ConstraintDiagnostic::Kind::Conflicting) + .value("PartiallyRedundant", ConstraintDiagnostic::Kind::PartiallyRedundant) + .value("Malformed", ConstraintDiagnostic::Kind::Malformed); + + py::enum_(m, "MotionKind") + .value("Rotational", MotionDef::Kind::Rotational) + .value("Translational", MotionDef::Kind::Translational) + .value("General", MotionDef::Kind::General); + + py::enum_(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_(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 ""; + }); + + py::class_(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_(m, "Constraint"); + + py::class_(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_(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_(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_(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_(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_(m, "SolveResult"); + + py::class_(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_(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_(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(std::move(py_solver_class)); + CreateSolverFn factory = [cls]() -> std::unique_ptr { + py::gil_scoped_acquire gil; + py::object instance = (*cls)(); + return std::make_unique(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."); +} diff --git a/src/Mod/Assembly/TestAssemblyWorkbench.py b/src/Mod/Assembly/TestAssemblyWorkbench.py index 387f4e4e14..87d83297fc 100644 --- a/src/Mod/Assembly/TestAssemblyWorkbench.py +++ b/src/Mod/Assembly/TestAssemblyWorkbench.py @@ -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)