From 7ea0078ba39a8bdc412e3c0bd61f22ddc27f7168 Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 17:20:23 -0600 Subject: [PATCH 1/3] 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) -- 2.49.1 From 406e120180e0c06f2abc2930a6b5264a71b214fa Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 18:59:05 -0600 Subject: [PATCH 2/3] docs: KCSolve architecture and Python API reference - Replace OndselSolver architecture doc with KCSolve pluggable solver architecture covering IKCSolver interface, SolverRegistry, OndselAdapter, Python bindings, file layout, and testing - Add kcsolve Python API reference with full type documentation, module functions, usage examples, and pybind11 vector-copy caveat - Add INTER_SOLVER.md spec (previously untracked) with Phase 1 and Phase 2 marked as complete - Update SUMMARY.md with new page links --- docs/INTER_SOLVER.md | 568 +++++++++++++++++++++++++ docs/src/SUMMARY.md | 3 +- docs/src/architecture/ondsel-solver.md | 137 +++++- docs/src/reference/kcsolve-python.md | 313 ++++++++++++++ 4 files changed, 1004 insertions(+), 17 deletions(-) create mode 100644 docs/INTER_SOLVER.md create mode 100644 docs/src/reference/kcsolve-python.md diff --git a/docs/INTER_SOLVER.md b/docs/INTER_SOLVER.md new file mode 100644 index 0000000000..2675724ba6 --- /dev/null +++ b/docs/INTER_SOLVER.md @@ -0,0 +1,568 @@ +# Pluggable Assembly Solver Architecture + +**Status:** Phase 2 complete +**Last Updated:** 2026-02-19 + +--- + +## 1. Problem + +Kindred Create currently vendors OndselSolver as a monolithic assembly constraint solver. Different engineering domains benefit from different solver strategies — Lagrangian methods work well for rigid body assemblies but poorly for over-constrained or soft-constraint systems. A pluggable architecture lets us ship multiple solvers (including experimental ones) without touching core assembly logic, and lets the server farm out solve jobs to headless worker processes. + +--- + +## 2. Design Goals + +1. **Stable C++ API** — A solver-agnostic interface that the Assembly module calls. Solvers are shared libraries loaded at runtime. +2. **Python binding layer** — Every C++ solver is exposed to Python via pybind11, enabling rapid prototyping, debugging, and server-side execution without a full GUI build. +3. **Solver-defined joint types** — Each solver declares its own joint/mate vocabulary, mapped from a common base set (inspired by SOLIDWORKS mates: coincident, concentric, tangent, distance, angle, lock, etc.). +4. **Semi-deterministic solving** — Consistent results given consistent input ordering, with configurable tolerance and iteration limits. +5. **Server-compatible** — Solvers run as detached processes claimed by `silorunner` workers via the existing job queue. + +--- + +## 3. Architecture Layers + +``` +┌──────────────────────────────────────────────────────┐ +│ Layer 4: Server / Worker │ +│ silorunner claims solve jobs, executes via Python │ +│ Headless Create or standalone solver process │ +├──────────────────────────────────────────────────────┤ +│ Layer 3: Python Debug & Scripting │ +│ pybind11 bindings for all solvers │ +│ Introspection, step-through, constraint viz │ +│ import kcsolve; s = kcsolve.load("ondsel") │ +├──────────────────────────────────────────────────────┤ +│ Layer 2: Solver Plugins (.so / .dll / .dylib) │ +│ Each implements IKCSolver interface │ +│ Registers joint types via manifest │ +│ Loaded by SolverRegistry at runtime │ +├──────────────────────────────────────────────────────┤ +│ Layer 1: C++ Solver API (libkcsolve) │ +│ IKCSolver, JointDef, SolveContext, SolveResult │ +│ SolverRegistry (discovery, loading, selection) │ +│ Ships as a shared library linked by Assembly module │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Layer 1: C++ Solver API + +Located at `src/Mod/Assembly/Solver/` (or `src/Lib/KCSolve/` if we want it independent of Assembly). + +### 4.1 Core Types + +```cpp +namespace KCSolve { + +// Unique identifier for a joint type within a solver +struct JointTypeId { + std::string solver_id; // e.g. "ondsel", "gnn", "relaxation" + std::string joint_name; // e.g. "coincident", "distance" +}; + +// Base joint categories (SOLIDWORKS-inspired vocabulary) +enum class BaseJointKind { + Coincident, + Concentric, + Tangent, + Distance, + Angle, + Lock, + Parallel, + Perpendicular, + PointOnLine, + SymmetricPlane, + Gear, + Rack, + Cam, + Slot, + Hinge, + Slider, + Cylindrical, + Planar, + Ball, + Screw, + Universal, + Custom // solver-specific extension +}; + +// A joint definition registered by a solver plugin +struct JointDef { + JointTypeId id; + BaseJointKind base_kind; // which vanilla category it maps to + std::string display_name; + std::string description; + uint32_t dof_removed; // degrees of freedom this joint removes + std::vector params; // parameter names (e.g. "distance", "angle") + bool supports_limits = false; + bool supports_friction = false; +}; + +// A constraint instance in a solve problem +struct Constraint { + JointTypeId joint_type; + std::string part_a; // part label or id + std::string part_b; + // Geometry references (face, edge, vertex indices) + std::vector refs_a; + std::vector refs_b; + std::map params; // param_name -> value + bool suppressed = false; +}; + +// Input to a solve operation +struct SolveContext { + std::vector constraints; + // Part placements as 4x4 transforms (initial guess) + std::map> placements; + // Which parts are grounded (fixed) + std::set grounded; + // Solver config + double tolerance = 1e-10; + uint32_t max_iterations = 500; + bool deterministic = true; // force consistent ordering + // Optional: previous solution for warm-starting + std::map> warm_start; +}; + +enum class SolveStatus { + Converged, + MaxIterationsReached, + Overconstrained, + Underconstrained, + Redundant, + Failed +}; + +struct ConstraintDiagnostic { + std::string constraint_id; + double residual; + bool satisfied; + std::string message; +}; + +struct SolveResult { + SolveStatus status; + uint32_t iterations; + double final_residual; + double solve_time_ms; + std::map> placements; + std::vector diagnostics; + // For semi-deterministic: hash of input ordering + uint64_t input_hash; +}; + +} // namespace KCSolve +``` + +### 4.2 Solver Interface + +```cpp +namespace KCSolve { + +class IKCSolver { +public: + virtual ~IKCSolver() = default; + + // Identity + virtual std::string id() const = 0; + virtual std::string name() const = 0; + virtual std::string version() const = 0; + + // Joint type registry — called once at load + virtual std::vector supported_joints() const = 0; + + // Solve + virtual SolveResult solve(const SolveContext& ctx) = 0; + + // Incremental: update a single constraint without full re-solve + // Default impl falls back to full solve + virtual SolveResult update(const SolveContext& ctx, + const std::string& changed_constraint) { + return solve(ctx); + } + + // Diagnostic: check if a constraint set is well-posed before solving + virtual SolveStatus diagnose(const SolveContext& ctx) { + return SolveStatus::Converged; // optimistic default + } + + // Determinism: given identical input, produce identical output + virtual bool is_deterministic() const { return false; } +}; + +// Plugin entry point — each .so exports this symbol +using CreateSolverFn = IKCSolver* (*)(); + +} // namespace KCSolve +``` + +### 4.3 Solver Registry + +```cpp +namespace KCSolve { + +class SolverRegistry { +public: + // Scan a directory for solver plugins (*.so / *.dll / *.dylib) + void scan(const std::filesystem::path& plugin_dir); + + // Manual registration (for built-in solvers like Ondsel) + void register_solver(std::unique_ptr solver); + + // Lookup + IKCSolver* get(const std::string& solver_id) const; + std::vector available() const; + + // Joint type resolution: find which solvers support a given base kind + std::vector joints_for(BaseJointKind kind) const; + + // Global default solver + void set_default(const std::string& solver_id); + IKCSolver* get_default() const; +}; + +} // namespace KCSolve +``` + +### 4.4 Plugin Loading + +Each solver plugin is a shared library exporting: + +```cpp +extern "C" KCSolve::IKCSolver* kcsolve_create(); +extern "C" const char* kcsolve_api_version(); // "1.0" +``` + +The registry `dlopen`s each library, checks `kcsolve_api_version()` compatibility, and calls `kcsolve_create()`. Plugins are discovered from: + +1. `/lib/kcsolve/` — system-installed solvers +2. `~/.config/KindredCreate/solvers/` — user-installed solvers +3. `KCSOLVE_PLUGIN_PATH` env var — development overrides + +--- + +## 5. Layer 2: OndselSolver Adapter + +The first plugin wraps the existing OndselSolver, mapping its internal constraint types to the `IKCSolver` interface. + +``` +src/Mod/Assembly/Solver/ +├── IKCSolver.h # Interface + types from §4 +├── SolverRegistry.cpp # Plugin discovery and loading +├── OndselAdapter.cpp # Wraps OndselSolver as IKCSolver plugin +└── CMakeLists.txt +``` + +`OndselAdapter` translates between `SolveContext` ↔ OndselSolver's Lagrangian formulation. This is the reference implementation and proves the API works before any new solvers are written. + +Joint mapping for OndselAdapter: + +| BaseJointKind | Ondsel Constraint | DOF Removed | +|---------------|-------------------|-------------| +| Coincident | PointOnPoint | 3 | +| Concentric | CylindricalOnCylindrical | 4 | +| Tangent | FaceOnFace (tangent mode) | 1 | +| Distance | PointOnPoint + offset | 2 | +| Angle | AxisAngle | 1 | +| Lock | FullLock | 6 | +| Hinge | RevoluteJoint | 5 | +| Slider | PrismaticJoint | 5 | +| Cylindrical | CylindricalJoint | 4 | +| Ball | SphericalJoint | 3 | + +--- + +## 6. Layer 3: Python Bindings + +### 6.1 pybind11 Module + +``` +src/Mod/Assembly/Solver/bindings/ +├── kcsolve_py.cpp # pybind11 module definition +└── CMakeLists.txt +``` + +```python +import kcsolve + +# List available solvers +print(kcsolve.available()) # ["ondsel", ...] + +# Load a solver +solver = kcsolve.load("ondsel") +print(solver.name, solver.version) +print(solver.supported_joints()) + +# Build a problem +ctx = kcsolve.SolveContext() +ctx.add_part("base", placement=..., grounded=True) +ctx.add_part("arm", placement=...) +ctx.add_constraint("coincident", "base", "arm", + refs_a=["Face6"], refs_b=["Face1"]) + +# Solve +result = solver.solve(ctx) +print(result.status) # SolveStatus.Converged +print(result.iterations) # 12 +print(result.solve_time_ms) # 3.4 +print(result.placements["arm"]) + +# Diagnostics per constraint +for d in result.diagnostics: + print(f"{d.constraint_id}: residual={d.residual:.2e} ok={d.satisfied}") +``` + +### 6.2 Debug / Introspection API + +The Python layer adds capabilities the C++ interface intentionally omits for performance: + +```python +# Step-through solving (debug mode) +debugger = kcsolve.Debugger(solver, ctx) +for step in debugger.iterate(): + print(f"iter {step.iteration}: residual={step.residual:.6e}") + print(f" moved: {step.parts_moved}") + print(f" worst constraint: {step.worst_constraint}") + if step.residual < 1e-8: + break + +# Constraint dependency graph +graph = kcsolve.dependency_graph(ctx) +# Returns dict: constraint_id -> [dependent_constraint_ids] + +# DOF analysis +analysis = kcsolve.dof_analysis(ctx) +print(f"Total DOF: {analysis.total_dof}") +print(f"Removed: {analysis.constrained_dof}") +print(f"Remaining: {analysis.free_dof}") +for part, dofs in analysis.per_part.items(): + print(f" {part}: {dofs} free") +``` + +### 6.3 Pure-Python Solver Support + +The Python layer also supports solvers written entirely in Python (no C++ required). This is the fast path for prototyping new approaches (GNN, relaxation, etc.): + +```python +class RelaxationSolver(kcsolve.PySolver): + """A pure-Python iterative relaxation solver for prototyping.""" + + id = "relaxation" + name = "Iterative Relaxation" + version = "0.1.0" + + def supported_joints(self): + return [ + kcsolve.JointDef("coincident", kcsolve.BaseJointKind.Coincident, dof_removed=3), + kcsolve.JointDef("distance", kcsolve.BaseJointKind.Distance, dof_removed=2), + # ... + ] + + def solve(self, ctx: kcsolve.SolveContext) -> kcsolve.SolveResult: + placements = dict(ctx.placements) + for i in range(ctx.max_iterations): + max_residual = 0.0 + for c in ctx.constraints: + residual = self._eval_constraint(c, placements) + correction = self._compute_correction(c, residual) + self._apply_correction(placements, c, correction) + max_residual = max(max_residual, abs(residual)) + if max_residual < ctx.tolerance: + return kcsolve.SolveResult( + status=kcsolve.SolveStatus.Converged, + iterations=i + 1, + final_residual=max_residual, + placements=placements + ) + return kcsolve.SolveResult( + status=kcsolve.SolveStatus.MaxIterationsReached, + iterations=ctx.max_iterations, + final_residual=max_residual, + placements=placements + ) + +# Register at runtime +kcsolve.register(RelaxationSolver()) +``` + +Python solvers are discovered from: +- `/solvers/*.py` — user-written solvers +- `mods/*/solvers/*.py` — addon-provided solvers + +--- + +## 7. Layer 4: Server Integration + +### 7.1 Solve Job Definition + +Extends the existing worker system (WORKERS.md) with a new job type: + +```yaml +job: + name: assembly-solve + version: 1 + description: "Solve assembly constraints using specified solver" + trigger: + type: revision_created + filter: + item_type: assembly + scope: + type: assembly + compute: + type: solve + command: create-solve + args: + solver: ondsel # or "auto" for registry default + tolerance: 1e-10 + max_iterations: 500 + deterministic: true + output_placements: true # write solved placements back to revision + output_diagnostics: true # store constraint diagnostics in job result + runner: + tags: [create, solver] + timeout: 300 + max_retries: 1 + priority: 75 +``` + +### 7.2 Headless Solve via Runner + +The `create-solve` command in `silorunner`: + +1. Claims job from Silo server +2. Downloads the assembly `.kc` file +3. Launches Headless Create (or standalone Python if pure-Python solver) +4. Loads the assembly, extracts constraint graph → `SolveContext` +5. Calls `solver.solve(ctx)` +6. Reports `SolveResult` back via `POST /api/runner/jobs/{id}/complete` +7. Optionally writes updated placements as a new revision + +### 7.3 Standalone Solve Process (No GUI) + +For server-side batch solving without Headless Create overhead: + +```python +#!/usr/bin/env python3 +"""Standalone solver worker — no FreeCAD dependency.""" +import kcsolve +import json, sys + +problem = json.load(sys.stdin) +ctx = kcsolve.SolveContext.from_dict(problem) + +solver = kcsolve.load(problem.get("solver", "ondsel")) +result = solver.solve(ctx) + +json.dump(result.to_dict(), sys.stdout) +``` + +This enables lightweight solver containers that don't need the full Create installation — useful for CI validation, quick constraint checks, and scaling solver capacity independently of geometry workers. + +--- + +## 8. Semi-Deterministic Behavior + +"Semi-deterministic" means: given the same constraint set and initial placements, the solver produces the same result. This is achieved by: + +1. **Canonical input ordering** — `SolveContext` sorts constraints and parts by a stable key (part label + constraint index) before passing to the solver. The ordering hash is stored in `SolveResult.input_hash`. + +2. **Solver contract** — `IKCSolver::is_deterministic()` reports whether the implementation guarantees this. OndselAdapter does (Lagrangian formulation with fixed pivot ordering). A GNN solver might not. + +3. **Tolerance-aware comparison** — Two `SolveResult`s are "equivalent" if all placement deltas are within tolerance, even if iteration counts differ. Used for regression testing. + +4. **Warm-start stability** — When `warm_start` placements are provided, the solver should converge to the same solution as a cold start (within tolerance), just faster. This is validated in the test suite. + +--- + +## 9. Implementation Phases + +### Phase 1: API + OndselAdapter (foundation) -- COMPLETE + +- Defined `IKCSolver.h`, core types (`Types.h`), `SolverRegistry` +- Implemented `OndselAdapter` wrapping existing solver +- Assembly module calls through `SolverRegistry` instead of directly calling OndselSolver +- 18 C++ tests, 6 Python integration tests +- **PR:** #297 (merged) + +### Phase 2: pybind11 Bindings -- COMPLETE + +- Built `kcsolve` pybind11 module exposing all enums, structs, and classes +- `PyIKCSolver` trampoline for pure-Python solver subclasses +- `register_solver()` for runtime Python solver registration +- `PySolverHolder` for GIL-safe forwarding of virtual calls +- 16 Python tests covering types, registry, and Python solver round-trips +- Debug/introspection API (Debugger, `dependency_graph()`, `dof_analysis()`) deferred to Phase 4+ +- Automatic Python solver discovery (`mods/*/solvers/`) deferred -- users call `register_solver()` explicitly +- **PR:** #298 +- **Docs:** `docs/src/architecture/ondsel-solver.md`, `docs/src/reference/kcsolve-python.md` + +### Phase 3: Server Integration + +- `create-solve` command for `silorunner` +- YAML job definition for solve jobs +- Standalone solver process (no FreeCAD dependency) +- `SolveContext` JSON serialization for inter-process communication +- **Deliverable:** Solve jobs run async through the worker system + +### Phase 4: Second Solver (validation) + +- Implement a simple relaxation or gradient-descent solver as a Python plugin +- Validates that the API actually supports different solving strategies +- Benchmark against OndselAdapter for correctness and performance +- **Deliverable:** Two interchangeable solvers, selectable per-assembly + +### Phase 5: GNN Solver (future) + +- Graph Neural Network approach from existing roadmap +- Likely a Python solver wrapping a trained model +- Focus on fast approximate solutions for interactive editing +- Falls back to OndselAdapter for final precision solve +- **Deliverable:** Hybrid solve pipeline (GNN fast-guess → Lagrangian refinement) + +--- + +## 10. File Locations + +``` +src/Lib/KCSolve/ # or src/Mod/Assembly/Solver/ +├── include/ +│ └── KCSolve/ +│ ├── IKCSolver.h # Interface + all types +│ ├── SolverRegistry.h # Plugin loading and lookup +│ └── Types.h # Enums, structs +├── src/ +│ ├── SolverRegistry.cpp +│ └── OndselAdapter.cpp +├── bindings/ +│ └── kcsolve_py.cpp # pybind11 +├── plugins/ # Additional compiled solver plugins +└── CMakeLists.txt +``` + +--- + +## 11. Open Questions + +1. **Location**: `src/Lib/KCSolve/` (independent library, usable without Assembly module) vs `src/Mod/Assembly/Solver/` (tighter coupling, simpler build)? Leaning toward `src/Lib/` since server workers need it without the full Assembly module. + +2. **Geometry abstraction**: The C++ API uses string references for faces/edges/vertices. Should we pass actual OCC geometry (TopoDS_Shape) through the interface, or keep it abstract and let each solver adapter resolve references? Abstract is more portable but adds a translation step. + +3. **Constraint persistence**: Currently constraints live in the FCStd XML. Should the pluggable layer introduce its own serialization, or always read/write through FreeCAD's property system? + +4. **API versioning**: `kcsolve_api_version()` returns a string. Semver with major-only breaking changes? How strict on backward compat for the plugin ABI? + +5. **License implications**: OndselSolver is LGPL. New solver plugins could be any license since they're loaded at runtime via a stable C API boundary. Confirm this interpretation. + +--- + +## 12. References + +- [ondsel-solver.md](ondsel-solver.md) — Current solver documentation +- [WORKERS.md](WORKERS.md) — Worker/runner job system +- [MULTI_USER_EDITS.md](MULTI_USER_EDITS.md) — Async validation pipeline +- [DAG.md](DAG.md) — Dependency graph for incremental recompute +- [ROADMAP.md](ROADMAP.md) — Tier 3 compute modules, GNN solver plans diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 66b437e86c..e4a1c93819 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -19,7 +19,7 @@ - [Python as Source of Truth](./architecture/python-source-of-truth.md) - [Silo Server](./architecture/silo-server.md) - [Signal Architecture](./architecture/signal-architecture.md) -- [OndselSolver](./architecture/ondsel-solver.md) +- [KCSolve: Pluggable Solver](./architecture/ondsel-solver.md) # Development @@ -64,3 +64,4 @@ - [OriginSelectorWidget](./reference/cpp-origin-selector-widget.md) - [FileOriginPython Bridge](./reference/cpp-file-origin-python.md) - [Creating a Custom Origin (C++)](./reference/cpp-custom-origin-guide.md) +- [KCSolve Python API](./reference/kcsolve-python.md) diff --git a/docs/src/architecture/ondsel-solver.md b/docs/src/architecture/ondsel-solver.md index 736bf9650c..96e6038c46 100644 --- a/docs/src/architecture/ondsel-solver.md +++ b/docs/src/architecture/ondsel-solver.md @@ -1,27 +1,132 @@ -# OndselSolver +# KCSolve: Pluggable Solver Architecture -OndselSolver is the assembly constraint solver used by FreeCAD's Assembly workbench. Kindred Create vendors a fork of the solver as a git submodule. +KCSolve is the pluggable assembly constraint solver framework for Kindred Create. It defines an abstract solver interface (`IKCSolver`) and a runtime registry (`SolverRegistry`) that lets the Assembly module work with any conforming solver backend. The default backend wraps OndselSolver via `OndselAdapter`. -- **Path:** `src/3rdParty/OndselSolver/` -- **Source:** `git.kindred-systems.com/kindred/solver` (Kindred fork) +- **Library:** `src/Mod/Assembly/Solver/` (builds `libKCSolve.so`) +- **Python module:** `src/Mod/Assembly/Solver/bindings/` (builds `kcsolve.so`) +- **Tests:** `tests/src/Mod/Assembly/Solver/` (C++), `src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py` (Python) -## How it works +## Architecture -The solver uses a **Lagrangian constraint formulation** to resolve assembly constraints (mates, joints, fixed positions). Given a set of parts with geometric constraints between them, it computes positions and orientations that satisfy all constraints simultaneously. +``` +┌──────────────────────────────────────────────────┐ +│ Assembly Module (AssemblyObject.cpp) │ +│ Builds SolveContext from FreeCAD document, │ +│ calls solver via SolverRegistry │ +├──────────────────────────────────────────────────┤ +│ SolverRegistry (singleton) │ +│ register_solver(), get(), available() │ +│ Plugin discovery via scan() / scan_default_paths │ +├──────────────┬───────────────────────────────────┤ +│ OndselAdapter │ Python solvers │ Future plugins │ +│ (C++ built-in)│ (via kcsolve) │ (.so plugins) │ +└──────────────┴───────────────────────────────────┘ +``` -The Assembly workbench (`src/Mod/Assembly/`) calls the solver whenever constraints are added or modified. Kindred Create has patches to `Assembly/` that extend `findPlacement()` for better datum and origin handling. +The Assembly module never references OndselSolver directly. All solver access goes through `SolverRegistry::instance().get()`, which returns a `std::unique_ptr`. -## Why a fork +## IKCSolver interface -The solver is forked from the upstream Ondsel project for: -- **Pinned stability** — the submodule is pinned to a known-good commit -- **Potential modifications** — the fork allows Kindred-specific patches if needed -- **Availability** — hosted on Kindred's Gitea instance for reliable access +A solver backend implements `IKCSolver` (defined in `IKCSolver.h`). Only three methods are pure virtual; all others have sensible defaults: -## Future: GNN solver +| Method | Required | Purpose | +|--------|----------|---------| +| `name()` | yes | Human-readable solver name | +| `supported_joints()` | yes | List of `BaseJointKind` values the solver handles | +| `solve(ctx)` | yes | Solve for static equilibrium | +| `update(ctx)` | no | Incremental re-solve after parameter changes | +| `pre_drag(ctx, parts)` | no | Begin interactive drag session | +| `drag_step(placements)` | no | One mouse-move during drag | +| `post_drag()` | no | End drag session | +| `run_kinematic(ctx)` | no | Run kinematic simulation | +| `num_frames()` | no | Frame count after simulation | +| `update_for_frame(i)` | no | Retrieve frame placements | +| `diagnose(ctx)` | no | Detect redundant/conflicting constraints | +| `is_deterministic()` | no | Whether output is reproducible (default: true) | +| `export_native(path)` | no | Write solver-native debug file (e.g. ASMT) | +| `supports_bundle_fixed()` | no | Whether solver handles Fixed-joint bundling internally | -There are plans to explore a Graph Neural Network (GNN) approach to constraint solving that could complement or supplement the Lagrangian solver for specific use cases. This is not yet implemented. +## Core types -## Related: GSL +All types live in `Types.h` with no FreeCAD dependencies, making the header standalone for future server/worker use. -The `src/3rdParty/GSL/` submodule is Microsoft's Guidelines Support Library (`github.com/microsoft/GSL`), providing C++ core guidelines utilities like `gsl::span` and `gsl::not_null`. It is a build dependency, not related to the constraint solver. +**Transform** -- position `[x, y, z]` + unit quaternion `[w, x, y, z]`. Equivalent to `Base::Placement` but independent. Note the quaternion convention differs from `Base::Rotation` which uses `(x, y, z, w)` ordering; the adapter layer handles the swap. + +**BaseJointKind** -- 24 primitive constraint types decomposed from FreeCAD's `JointType` and `DistanceType` enums. Covers point constraints (Coincident, PointOnLine, PointInPlane), axis/surface constraints (Concentric, Tangent, Planar), kinematic joints (Fixed, Revolute, Cylindrical, Slider, Ball, Screw, Universal), mechanical elements (Gear, RackPinion), distance variants, and a `Custom` extension point. + +**SolveContext** -- complete solver input: parts (with placements, mass, grounded flag), constraints (with markers, parameters, limits), optional motion definitions and simulation parameters. + +**SolveResult** -- solver output: status code, updated part placements, DOF count, constraint diagnostics, and simulation frame count. + +## SolverRegistry + +Thread-safe singleton managing solver backends: + +```cpp +auto& reg = SolverRegistry::instance(); + +// Registration (at module init) +reg.register_solver("ondsel", []() { + return std::make_unique(); +}); + +// Retrieval +auto solver = reg.get(); // default solver +auto solver = reg.get("ondsel"); // by name + +// Queries +reg.available(); // ["ondsel", ...] +reg.joints_for("ondsel"); // [Fixed, Revolute, ...] +reg.set_default("ondsel"); +``` + +Plugin discovery scans directories for shared libraries exporting `kcsolve_api_version()` and `kcsolve_create()`. Default paths: `KCSOLVE_PLUGIN_PATH` env var and `/lib/kcsolve/`. + +## OndselAdapter + +The built-in solver backend wrapping OndselSolver's Lagrangian constraint formulation. Registered as `"ondsel"` at Assembly module initialization. + +Supports all 24 joint types. The adapter translates between `SolveContext`/`SolveResult` and OndselSolver's internal `ASMTAssembly` representation, including: + +- Part placement conversion (Transform <-> Base::Placement quaternion ordering) +- Constraint parameter mapping (BaseJointKind -> OndselSolver joint classes) +- Interactive drag protocol (pre_drag/drag_step/post_drag) +- Kinematic simulation (run_kinematic/num_frames/update_for_frame) +- Constraint diagnostics (redundancy detection via MbD system) + +## Python bindings (kcsolve module) + +The `kcsolve` pybind11 module exposes the full C++ API to Python. See [KCSolve Python API](../reference/kcsolve-python.md) for details. + +Key capabilities: +- All enums, structs, and classes accessible from Python +- Subclass `IKCSolver` in pure Python to create new solver backends +- Register Python solvers at runtime via `kcsolve.register_solver()` +- Query the registry from the FreeCAD console + +## File layout + +``` +src/Mod/Assembly/Solver/ +├── Types.h # Enums and structs (no FreeCAD deps) +├── IKCSolver.h # Abstract solver interface +├── SolverRegistry.h/cpp # Singleton registry + plugin loading +├── OndselAdapter.h/cpp # OndselSolver wrapper +├── KCSolveGlobal.h # DLL export macros +├── CMakeLists.txt # Builds libKCSolve.so +└── bindings/ + ├── PyIKCSolver.h # pybind11 trampoline for Python subclasses + ├── kcsolve_py.cpp # Module definition (enums, structs, classes) + └── CMakeLists.txt # Builds kcsolve.so (pybind11 module) +``` + +## Testing + +- **18 C++ tests** (`KCSolve_tests_run`) covering SolverRegistry (8 tests) and OndselAdapter (10 tests including drag protocol and redundancy diagnosis) +- **16 Python tests** (`TestKCSolvePy`) covering module import, type bindings, registry functions, Python solver subclassing, and full register/load/solve round-trips +- **6 Python integration tests** (`TestSolverIntegration`) testing solver behavior through FreeCAD document objects + +## Related + +- [KCSolve Python API Reference](../reference/kcsolve-python.md) +- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- full architecture specification diff --git a/docs/src/reference/kcsolve-python.md b/docs/src/reference/kcsolve-python.md new file mode 100644 index 0000000000..f99ac7a1cd --- /dev/null +++ b/docs/src/reference/kcsolve-python.md @@ -0,0 +1,313 @@ +# KCSolve Python API Reference + +The `kcsolve` module provides Python access to the KCSolve pluggable solver framework. It is built with pybind11 and installed alongside the Assembly module. + +```python +import kcsolve +``` + +## Module constants + +| Name | Value | Description | +|------|-------|-------------| +| `API_VERSION_MAJOR` | `1` | KCSolve API major version | + +## Enums + +### BaseJointKind + +Primitive constraint types. 24 values: + +`Coincident`, `PointOnLine`, `PointInPlane`, `Concentric`, `Tangent`, `Planar`, `LineInPlane`, `Parallel`, `Perpendicular`, `Angle`, `Fixed`, `Revolute`, `Cylindrical`, `Slider`, `Ball`, `Screw`, `Universal`, `Gear`, `RackPinion`, `Cam`, `Slot`, `DistancePointPoint`, `DistanceCylSph`, `Custom` + +### SolveStatus + +| Value | Meaning | +|-------|---------| +| `Success` | Solve converged | +| `Failed` | Solve did not converge | +| `InvalidFlip` | Orientation flipped past threshold | +| `NoGroundedParts` | No grounded parts in assembly | + +### DiagnosticKind + +`Redundant`, `Conflicting`, `PartiallyRedundant`, `Malformed` + +### MotionKind + +`Rotational`, `Translational`, `General` + +### LimitKind + +`TranslationMin`, `TranslationMax`, `RotationMin`, `RotationMax` + +## Structs + +### Transform + +Rigid-body transform: position + unit quaternion. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `position` | `list[float]` (3) | `[0, 0, 0]` | Translation (x, y, z) | +| `quaternion` | `list[float]` (4) | `[1, 0, 0, 0]` | Unit quaternion (w, x, y, z) | + +```python +t = kcsolve.Transform() +t = kcsolve.Transform.identity() # same as default +``` + +Note: quaternion convention is `(w, x, y, z)`, which differs from FreeCAD's `Base.Rotation(x, y, z, w)`. The adapter layer handles conversion. + +### Part + +| Field | Type | Default | +|-------|------|---------| +| `id` | `str` | `""` | +| `placement` | `Transform` | identity | +| `mass` | `float` | `1.0` | +| `grounded` | `bool` | `False` | + +### Constraint + +| Field | Type | Default | +|-------|------|---------| +| `id` | `str` | `""` | +| `part_i` | `str` | `""` | +| `marker_i` | `Transform` | identity | +| `part_j` | `str` | `""` | +| `marker_j` | `Transform` | identity | +| `type` | `BaseJointKind` | `Coincident` | +| `params` | `list[float]` | `[]` | +| `limits` | `list[Constraint.Limit]` | `[]` | +| `activated` | `bool` | `True` | + +### Constraint.Limit + +| Field | Type | Default | +|-------|------|---------| +| `kind` | `LimitKind` | `TranslationMin` | +| `value` | `float` | `0.0` | +| `tolerance` | `float` | `1e-9` | + +### MotionDef + +| Field | Type | Default | +|-------|------|---------| +| `kind` | `MotionKind` | `Rotational` | +| `joint_id` | `str` | `""` | +| `marker_i` | `str` | `""` | +| `marker_j` | `str` | `""` | +| `rotation_expr` | `str` | `""` | +| `translation_expr` | `str` | `""` | + +### SimulationParams + +| Field | Type | Default | +|-------|------|---------| +| `t_start` | `float` | `0.0` | +| `t_end` | `float` | `1.0` | +| `h_out` | `float` | `0.01` | +| `h_min` | `float` | `1e-9` | +| `h_max` | `float` | `1.0` | +| `error_tol` | `float` | `1e-6` | + +### SolveContext + +| Field | Type | Default | +|-------|------|---------| +| `parts` | `list[Part]` | `[]` | +| `constraints` | `list[Constraint]` | `[]` | +| `motions` | `list[MotionDef]` | `[]` | +| `simulation` | `SimulationParams` or `None` | `None` | +| `bundle_fixed` | `bool` | `False` | + +**Important:** pybind11 returns copies of `list` fields, not references. Use whole-list assignment: + +```python +ctx = kcsolve.SolveContext() +p = kcsolve.Part() +p.id = "box1" +ctx.parts = [p] # correct +# ctx.parts.append(p) # does NOT modify ctx +``` + +### ConstraintDiagnostic + +| Field | Type | Default | +|-------|------|---------| +| `constraint_id` | `str` | `""` | +| `kind` | `DiagnosticKind` | `Redundant` | +| `detail` | `str` | `""` | + +### SolveResult + +| Field | Type | Default | +|-------|------|---------| +| `status` | `SolveStatus` | `Success` | +| `placements` | `list[SolveResult.PartResult]` | `[]` | +| `dof` | `int` | `-1` | +| `diagnostics` | `list[ConstraintDiagnostic]` | `[]` | +| `num_frames` | `int` | `0` | + +### SolveResult.PartResult + +| Field | Type | Default | +|-------|------|---------| +| `id` | `str` | `""` | +| `placement` | `Transform` | identity | + +## Classes + +### IKCSolver + +Abstract base class for solver backends. Subclass in Python to create custom solvers. + +Three methods must be implemented: + +```python +class MySolver(kcsolve.IKCSolver): + def name(self): + return "My Solver" + + def supported_joints(self): + return [kcsolve.BaseJointKind.Fixed, kcsolve.BaseJointKind.Revolute] + + def solve(self, ctx): + result = kcsolve.SolveResult() + result.status = kcsolve.SolveStatus.Success + return result +``` + +Optional overrides (all have default implementations): + +| Method | Default behavior | +|--------|-----------------| +| `update(ctx)` | Delegates to `solve()` | +| `pre_drag(ctx, drag_parts)` | Delegates to `solve()` | +| `drag_step(drag_placements)` | Returns Success with no placements | +| `post_drag()` | No-op | +| `run_kinematic(ctx)` | Returns Failed | +| `num_frames()` | Returns 0 | +| `update_for_frame(index)` | Returns Failed | +| `diagnose(ctx)` | Returns empty list | +| `is_deterministic()` | Returns `True` | +| `export_native(path)` | No-op | +| `supports_bundle_fixed()` | Returns `False` | + +### OndselAdapter + +Built-in solver wrapping OndselSolver's Lagrangian constraint formulation. Inherits `IKCSolver`. + +```python +solver = kcsolve.OndselAdapter() +solver.name() # "OndselSolver (Lagrangian)" +``` + +In practice, use `kcsolve.load("ondsel")` rather than constructing directly, as this goes through the registry. + +## Module functions + +### available() + +Return names of all registered solvers. + +```python +kcsolve.available() # ["ondsel"] +``` + +### load(name="") + +Create an instance of the named solver. If `name` is empty, uses the default. Returns `None` if the solver is not found. + +```python +solver = kcsolve.load("ondsel") +solver = kcsolve.load() # default solver +``` + +### joints_for(name) + +Query supported joint types for a registered solver. + +```python +joints = kcsolve.joints_for("ondsel") +# [BaseJointKind.Coincident, BaseJointKind.Fixed, ...] +``` + +### set_default(name) + +Set the default solver name. Returns `True` if the name is registered. + +```python +kcsolve.set_default("ondsel") # True +kcsolve.set_default("unknown") # False +``` + +### get_default() + +Get the current default solver name. + +```python +kcsolve.get_default() # "ondsel" +``` + +### register_solver(name, solver_class) + +Register a Python solver class with the SolverRegistry. `solver_class` must be a callable that returns an `IKCSolver` subclass instance. + +```python +class MySolver(kcsolve.IKCSolver): + def name(self): return "MySolver" + def supported_joints(self): return [kcsolve.BaseJointKind.Fixed] + def solve(self, ctx): + r = kcsolve.SolveResult() + r.status = kcsolve.SolveStatus.Success + return r + +kcsolve.register_solver("my_solver", MySolver) +solver = kcsolve.load("my_solver") +``` + +## Complete example + +```python +import kcsolve + +# Build a two-part assembly with a Fixed joint +ctx = kcsolve.SolveContext() + +base = kcsolve.Part() +base.id = "base" +base.grounded = True + +arm = kcsolve.Part() +arm.id = "arm" +arm.placement.position = [100.0, 0.0, 0.0] + +joint = kcsolve.Constraint() +joint.id = "Joint001" +joint.part_i = "base" +joint.part_j = "arm" +joint.type = kcsolve.BaseJointKind.Fixed + +ctx.parts = [base, arm] +ctx.constraints = [joint] + +# Solve +solver = kcsolve.load("ondsel") +result = solver.solve(ctx) + +print(result.status) # SolveStatus.Success +for pr in result.placements: + print(f"{pr.id}: pos={list(pr.placement.position)}") + +# Diagnostics +diags = solver.diagnose(ctx) +for d in diags: + print(f"{d.constraint_id}: {d.kind} - {d.detail}") +``` + +## Related + +- [KCSolve Architecture](../architecture/ondsel-solver.md) +- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- full architecture specification -- 2.49.1 From bd43e62822a0193d3cd80f1af51a4465923dca2e Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 19:06:08 -0600 Subject: [PATCH 3/3] docs(kcsolve): expand Python API reference with full method docs Expand SolveContext field descriptions (motions, simulation, bundle_fixed), Constraint params table, marker explanations, Constraint.Limit descriptions, MotionDef field descriptions, SimulationParams field descriptions, and all optional IKCSolver methods with signatures, parameter docs, and usage examples (interactive drag protocol, kinematic simulation, diagnostics, export_native, capability queries). --- docs/src/reference/kcsolve-python.md | 222 ++++++++++++++++++++------- 1 file changed, 169 insertions(+), 53 deletions(-) diff --git a/docs/src/reference/kcsolve-python.md b/docs/src/reference/kcsolve-python.md index f99ac7a1cd..2a75739813 100644 --- a/docs/src/reference/kcsolve-python.md +++ b/docs/src/reference/kcsolve-python.md @@ -70,57 +70,92 @@ Note: quaternion convention is `(w, x, y, z)`, which differs from FreeCAD's `Bas ### Constraint -| Field | Type | Default | -|-------|------|---------| -| `id` | `str` | `""` | -| `part_i` | `str` | `""` | -| `marker_i` | `Transform` | identity | -| `part_j` | `str` | `""` | -| `marker_j` | `Transform` | identity | -| `type` | `BaseJointKind` | `Coincident` | -| `params` | `list[float]` | `[]` | -| `limits` | `list[Constraint.Limit]` | `[]` | -| `activated` | `bool` | `True` | +A constraint between two parts, built from a FreeCAD JointObject by the adapter layer. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `id` | `str` | `""` | FreeCAD document object name (e.g. `"Joint001"`) | +| `part_i` | `str` | `""` | Solver-side part ID for first reference | +| `marker_i` | `Transform` | identity | Coordinate system on `part_i` (attachment point/orientation) | +| `part_j` | `str` | `""` | Solver-side part ID for second reference | +| `marker_j` | `Transform` | identity | Coordinate system on `part_j` (attachment point/orientation) | +| `type` | `BaseJointKind` | `Coincident` | Constraint type | +| `params` | `list[float]` | `[]` | Scalar parameters (interpretation depends on `type`) | +| `limits` | `list[Constraint.Limit]` | `[]` | Joint travel limits | +| `activated` | `bool` | `True` | Whether this constraint is active | + +**`marker_i` / `marker_j`** -- Define the local coordinate frames on each part where the joint acts. For example, a Revolute joint's markers define the hinge axis direction and attachment points on each part. + +**`params`** -- Interpretation depends on `type`: + +| Type | params[0] | params[1] | +|------|-----------|-----------| +| `Angle` | angle (radians) | | +| `RackPinion` | pitch radius | | +| `Screw` | pitch | | +| `Gear` | radius I | radius J (negative for belt) | +| `DistancePointPoint` | distance | | +| `DistanceCylSph` | distance | | +| `Planar` | offset | | +| `Concentric` | distance | | +| `PointInPlane` | offset | | +| `LineInPlane` | offset | | ### Constraint.Limit -| Field | Type | Default | -|-------|------|---------| -| `kind` | `LimitKind` | `TranslationMin` | -| `value` | `float` | `0.0` | -| `tolerance` | `float` | `1e-9` | +Joint travel limits (translation or rotation bounds). + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `kind` | `LimitKind` | `TranslationMin` | Which degree of freedom to limit | +| `value` | `float` | `0.0` | Limit value (meters for translation, radians for rotation) | +| `tolerance` | `float` | `1e-9` | Solver tolerance for limit enforcement | ### MotionDef -| Field | Type | Default | -|-------|------|---------| -| `kind` | `MotionKind` | `Rotational` | -| `joint_id` | `str` | `""` | -| `marker_i` | `str` | `""` | -| `marker_j` | `str` | `""` | -| `rotation_expr` | `str` | `""` | -| `translation_expr` | `str` | `""` | +A motion driver for kinematic simulation. Defines time-dependent actuation of a constraint. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `kind` | `MotionKind` | `Rotational` | Type of motion: `Rotational`, `Translational`, or `General` (both) | +| `joint_id` | `str` | `""` | ID of the constraint this motion drives | +| `marker_i` | `str` | `""` | Reference marker on first part | +| `marker_j` | `str` | `""` | Reference marker on second part | +| `rotation_expr` | `str` | `""` | Rotation law as a function of time `t` (e.g. `"2*pi*t"`) | +| `translation_expr` | `str` | `""` | Translation law as a function of time `t` (e.g. `"10*t"`) | + +For `Rotational` kind, only `rotation_expr` is used. For `Translational`, only `translation_expr`. For `General`, both are set. ### SimulationParams -| Field | Type | Default | -|-------|------|---------| -| `t_start` | `float` | `0.0` | -| `t_end` | `float` | `1.0` | -| `h_out` | `float` | `0.01` | -| `h_min` | `float` | `1e-9` | -| `h_max` | `float` | `1.0` | -| `error_tol` | `float` | `1e-6` | +Time-stepping parameters for kinematic simulation via `run_kinematic()`. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `t_start` | `float` | `0.0` | Simulation start time (seconds) | +| `t_end` | `float` | `1.0` | Simulation end time (seconds) | +| `h_out` | `float` | `0.01` | Output time step -- controls frame rate (e.g. `0.04` = 25 fps) | +| `h_min` | `float` | `1e-9` | Minimum internal integration step | +| `h_max` | `float` | `1.0` | Maximum internal integration step | +| `error_tol` | `float` | `1e-6` | Error tolerance for adaptive time stepping | ### SolveContext -| Field | Type | Default | -|-------|------|---------| -| `parts` | `list[Part]` | `[]` | -| `constraints` | `list[Constraint]` | `[]` | -| `motions` | `list[MotionDef]` | `[]` | -| `simulation` | `SimulationParams` or `None` | `None` | -| `bundle_fixed` | `bool` | `False` | +Complete input to a solve operation. Built by the adapter layer from FreeCAD document objects, or constructed manually for scripted solving. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `parts` | `list[Part]` | `[]` | All parts in the assembly | +| `constraints` | `list[Constraint]` | `[]` | Constraints between parts | +| `motions` | `list[MotionDef]` | `[]` | Motion drivers for kinematic simulation | +| `simulation` | `SimulationParams` or `None` | `None` | Time-stepping parameters for `run_kinematic()` | +| `bundle_fixed` | `bool` | `False` | Hint to merge Fixed-joint-connected parts into rigid bodies | + +**`motions`** -- Motion drivers define time-dependent joint actuation for kinematic simulation. Each `MotionDef` references a constraint by `joint_id` and provides expressions (functions of time `t`) for rotation and/or translation. Only used when calling `run_kinematic()`. + +**`simulation`** -- When set, provides time-stepping parameters (`t_start`, `t_end`, step sizes, error tolerance) for kinematic simulation via `run_kinematic()`. When `None`, kinematic simulation is not requested. + +**`bundle_fixed`** -- When `True`, parts connected by `Fixed` joints should be merged into single rigid bodies before solving, reducing the problem size. If the solver reports `supports_bundle_fixed() == True`, it handles this internally. Otherwise, the caller (adapter layer) pre-bundles before building the context. **Important:** pybind11 returns copies of `list` fields, not references. Use whole-list assignment: @@ -179,21 +214,102 @@ class MySolver(kcsolve.IKCSolver): return result ``` -Optional overrides (all have default implementations): +All other methods are optional and have default implementations. Override them to add capabilities beyond basic solving. -| Method | Default behavior | -|--------|-----------------| -| `update(ctx)` | Delegates to `solve()` | -| `pre_drag(ctx, drag_parts)` | Delegates to `solve()` | -| `drag_step(drag_placements)` | Returns Success with no placements | -| `post_drag()` | No-op | -| `run_kinematic(ctx)` | Returns Failed | -| `num_frames()` | Returns 0 | -| `update_for_frame(index)` | Returns Failed | -| `diagnose(ctx)` | Returns empty list | -| `is_deterministic()` | Returns `True` | -| `export_native(path)` | No-op | -| `supports_bundle_fixed()` | Returns `False` | +#### update(ctx) -> SolveResult + +Incrementally re-solve after parameter changes (e.g. joint angle adjusted during creation). Solvers can optimize this path since only parameters changed, not topology. Default: delegates to `solve()`. + +```python +def update(self, ctx): + # Only re-evaluate changed constraints, reuse cached factorization + return self._incremental_solve(ctx) +``` + +#### Interactive drag protocol + +Three-phase protocol for interactive part dragging in the viewport. Solvers can maintain internal state across the drag session for better performance. + +**pre_drag(ctx, drag_parts) -> SolveResult** -- Prepare for a drag session. `drag_parts` is a `list[str]` of part IDs being dragged. Solve the initial state and cache internal data. Default: delegates to `solve()`. + +**drag_step(drag_placements) -> SolveResult** -- Called on each mouse move. `drag_placements` is a `list[SolveResult.PartResult]` with the current positions of dragged parts. Returns updated placements for all affected parts. Default: returns Success with no placements. + +**post_drag()** -- End the drag session and release internal state. Default: no-op. + +```python +def pre_drag(self, ctx, drag_parts): + self._cached_system = self._build_system(ctx) + return self.solve(ctx) + +def drag_step(self, drag_placements): + # Use cached system for fast incremental solve + for dp in drag_placements: + self._cached_system.set_placement(dp.id, dp.placement) + return self._cached_system.solve_incremental() + +def post_drag(self): + self._cached_system = None +``` + +#### Kinematic simulation + +**run_kinematic(ctx) -> SolveResult** -- Run a kinematic simulation over the time range in `ctx.simulation`. After this call, `num_frames()` returns the frame count and `update_for_frame(i)` retrieves individual frames. Requires `ctx.simulation` to be set and `ctx.motions` to contain at least one motion driver. Default: returns Failed. + +**num_frames() -> int** -- Number of simulation frames available after `run_kinematic()`. Default: returns 0. + +**update_for_frame(index) -> SolveResult** -- Retrieve part placements for simulation frame at `index` (0-based, must be < `num_frames()`). Default: returns Failed. + +```python +# Run a kinematic simulation +ctx.simulation = kcsolve.SimulationParams() +ctx.simulation.t_start = 0.0 +ctx.simulation.t_end = 2.0 +ctx.simulation.h_out = 0.04 # 25 fps + +motion = kcsolve.MotionDef() +motion.kind = kcsolve.MotionKind.Rotational +motion.joint_id = "Joint001" +motion.rotation_expr = "2*pi*t" # one revolution per second +ctx.motions = [motion] + +solver = kcsolve.load("ondsel") +result = solver.run_kinematic(ctx) + +for i in range(solver.num_frames()): + frame = solver.update_for_frame(i) + for pr in frame.placements: + print(f"frame {i}: {pr.id} at {list(pr.placement.position)}") +``` + +#### diagnose(ctx) -> list[ConstraintDiagnostic] + +Analyze the assembly for redundant, conflicting, or malformed constraints. May require a prior `solve()` call for some solvers. Returns a list of `ConstraintDiagnostic` objects. Default: returns empty list. + +```python +diags = solver.diagnose(ctx) +for d in diags: + if d.kind == kcsolve.DiagnosticKind.Redundant: + print(f"Redundant: {d.constraint_id} - {d.detail}") + elif d.kind == kcsolve.DiagnosticKind.Conflicting: + print(f"Conflict: {d.constraint_id} - {d.detail}") +``` + +#### is_deterministic() -> bool + +Whether this solver produces identical results given identical input. Used for regression testing and result caching. Default: returns `True`. + +#### export_native(path) + +Write a solver-native debug/diagnostic file (e.g. ASMT format for OndselSolver). Requires a prior `solve()` or `run_kinematic()` call. Default: no-op. + +```python +solver.solve(ctx) +solver.export_native("/tmp/debug.asmt") +``` + +#### supports_bundle_fixed() -> bool + +Whether this solver handles Fixed-joint part bundling internally. When `False`, the caller merges Fixed-joint-connected parts into single rigid bodies before building the `SolveContext`, reducing problem size. When `True`, the solver receives unbundled parts and optimizes internally. Default: returns `False`. ### OndselAdapter -- 2.49.1