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:
237
src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py
Normal file
237
src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py
Normal 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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
src/Mod/Assembly/Solver/bindings/CMakeLists.txt
Normal file
31
src/Mod/Assembly/Solver/bindings/CMakeLists.txt
Normal 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})
|
||||
121
src/Mod/Assembly/Solver/bindings/PyIKCSolver.h
Normal file
121
src/Mod/Assembly/Solver/bindings/PyIKCSolver.h
Normal 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
|
||||
359
src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp
Normal file
359
src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp
Normal 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.");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user