From 6ee90727fb8e71fb0ea06e58762f482592d94b8f Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 20 Feb 2026 23:33:36 -0600 Subject: [PATCH] =?UTF-8?q?feat(solver):=20Phase=205=20assembly=20integrat?= =?UTF-8?q?ion=20=E2=80=94=20plug=20Kindred=20solver=20into=20Assembly=20w?= =?UTF-8?q?orkbench=20(#289=20phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make solver selection preference-driven: reads Solver param from Assembly preferences (default falls through to registry default) - Add resetSolver() to AssemblyObject for preference change support - Add solver backend combo box to Assembly preferences UI - Update solver submodule to include phases 2-5: - Phase 2: full constraint vocabulary (24 BaseJointKind types) - Phase 3: graph decomposition for cluster-by-cluster solving - Phase 4: diagnostics, half-space preference, weight vectors - Phase 5: _build_system extraction, diagnose(), drag protocol - Add TestKindredSolverIntegration: full-stack tests with kindred backend - KindredSolver now implements solve, diagnose, pre_drag/drag_step/post_drag --- mods/solver | 2 +- src/Mod/Assembly/App/AssemblyObject.cpp | 6 +- src/Mod/Assembly/App/AssemblyObject.h | 1 + .../TestKindredSolverIntegration.py | 180 ++++++++++++++++++ src/Mod/Assembly/CMakeLists.txt | 1 + .../Gui/Resources/preferences/Assembly.ui | 14 ++ src/Mod/Assembly/Preferences.py | 23 ++- src/Mod/Assembly/TestAssemblyWorkbench.py | 2 + 8 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py diff --git a/mods/solver b/mods/solver index 98051ba0c9..adaa0f9a69 160000 --- a/mods/solver +++ b/mods/solver @@ -1 +1 @@ -Subproject commit 98051ba0c9df0438f22d8b5bbcfc51b105909886 +Subproject commit adaa0f9a690a311db553158eed82d88f1f911913 diff --git a/src/Mod/Assembly/App/AssemblyObject.cpp b/src/Mod/Assembly/App/AssemblyObject.cpp index bb55bc6f00..26ff5ec4af 100644 --- a/src/Mod/Assembly/App/AssemblyObject.cpp +++ b/src/Mod/Assembly/App/AssemblyObject.cpp @@ -147,7 +147,11 @@ void AssemblyObject::onChanged(const App::Property* prop) KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver() { if (!solver_) { - solver_ = KCSolve::SolverRegistry::instance().get("ondsel"); + ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/Mod/Assembly"); + std::string solverName = hGrp->GetASCII("Solver", ""); + solver_ = KCSolve::SolverRegistry::instance().get(solverName); + // get("") returns the registry default (first registered solver) } return solver_.get(); } diff --git a/src/Mod/Assembly/App/AssemblyObject.h b/src/Mod/Assembly/App/AssemblyObject.h index c01e69a486..6355040f5c 100644 --- a/src/Mod/Assembly/App/AssemblyObject.h +++ b/src/Mod/Assembly/App/AssemblyObject.h @@ -98,6 +98,7 @@ public: void postDrag(); void savePlacementsForUndo(); void undoSolve(); + void resetSolver() { solver_.reset(); } void clearUndo(); void exportAsASMT(std::string fileName); diff --git a/src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py b/src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py new file mode 100644 index 0000000000..bd69477183 --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py @@ -0,0 +1,180 @@ +# 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 * +# . * +# * +# ***************************************************************************/ + +""" +Integration tests for the Kindred solver backend. + +These tests mirror TestSolverIntegration but force the solver preference +to "kindred" so the full pipeline (AssemblyObject → IKCSolver → +KindredSolver) is exercised. +""" + +import unittest + +import FreeCAD as App +import JointObject + + +def _pref(): + return App.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly") + + +class TestKindredSolverIntegration(unittest.TestCase): + """Full-stack solver tests using the Kindred (Newton-Raphson) backend.""" + + def setUp(self): + # Force the kindred solver backend + self._prev_solver = _pref().GetString("Solver", "") + _pref().SetString("Solver", "kindred") + + doc_name = self.__class__.__name__ + if App.ActiveDocument: + if App.ActiveDocument.Name != doc_name: + App.newDocument(doc_name) + else: + App.newDocument(doc_name) + App.setActiveDocument(doc_name) + self.doc = App.ActiveDocument + + self.assembly = self.doc.addObject("Assembly::AssemblyObject", "Assembly") + # Reset the solver so it picks up the new preference + self.assembly.resetSolver() + self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints") + + def tearDown(self): + App.closeDocument(self.doc.Name) + _pref().SetString("Solver", self._prev_solver) + + # ── Helpers ───────────────────────────────────────────────────── + + def _make_box(self, x=0, y=0, z=0, size=10): + box = self.assembly.newObject("Part::Box", "Box") + box.Length = size + box.Width = size + box.Height = size + box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation()) + return box + + def _ground(self, obj): + gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint") + JointObject.GroundedJoint(gnd, obj) + return gnd + + def _make_joint(self, joint_type, ref1, ref2): + joint = self.jointgroup.newObject("App::FeaturePython", "Joint") + JointObject.Joint(joint, joint_type) + refs = [ + [ref1[0], ref1[1]], + [ref2[0], ref2[1]], + ] + joint.Proxy.setJointConnectors(joint, refs) + return joint + + # ── Tests ─────────────────────────────────────────────────────── + + def test_solve_fixed_joint(self): + """Two boxes + grounded + fixed joint -> placements match.""" + box1 = self._make_box(10, 20, 30) + box2 = self._make_box(40, 50, 60) + self._ground(box2) + + self._make_joint( + 0, + [box2, ["Face6", "Vertex7"]], + [box1, ["Face6", "Vertex7"]], + ) + + self.assertTrue( + box1.Placement.isSame(box2.Placement, 1e-6), + "Fixed joint: box1 should match box2 placement", + ) + + def test_solve_revolute_joint(self): + """Two boxes + grounded + revolute joint -> solve succeeds.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(100, 0, 0) + self._ground(box1) + + self._make_joint( + 1, + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ) + + result = self.assembly.solve() + self.assertEqual(result, 0, "Revolute joint solve should succeed") + + def test_solve_returns_code_for_no_ground(self): + """Assembly with no grounded parts -> solve returns -6.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(50, 0, 0) + + joint = self.jointgroup.newObject("App::FeaturePython", "Joint") + JointObject.Joint(joint, 0) + refs = [ + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ] + joint.Proxy.setJointConnectors(joint, refs) + + result = self.assembly.solve() + self.assertEqual(result, -6, "No grounded parts should return -6") + + def test_solve_dof_reporting(self): + """Revolute joint -> DOF = 1.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(100, 0, 0) + self._ground(box1) + + self._make_joint( + 1, + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ) + + self.assembly.solve() + dof = self.assembly.getLastDoF() + self.assertEqual(dof, 1, "Revolute joint should leave 1 DOF") + + def test_solve_stability(self): + """Solving twice produces identical placements.""" + box1 = self._make_box(10, 20, 30) + box2 = self._make_box(40, 50, 60) + self._ground(box2) + + self._make_joint( + 0, + [box2, ["Face6", "Vertex7"]], + [box1, ["Face6", "Vertex7"]], + ) + + self.assembly.solve() + plc_first = App.Placement(box1.Placement) + + self.assembly.solve() + plc_second = box1.Placement + + self.assertTrue( + plc_first.isSame(plc_second, 1e-6), + "Deterministic solver should produce identical results", + ) diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt index b8ceb8a5f5..0e9b49d7d6 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/TestKindredSolverIntegration.py AssemblyTests/TestKCSolvePy.py AssemblyTests/mocks/__init__.py AssemblyTests/mocks/MockGui.py diff --git a/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui b/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui index 10ebe705fe..513b4a589c 100644 --- a/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui +++ b/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui @@ -84,6 +84,20 @@ The files are named "runPreDrag.asmt" and "dragging.log" and are located in the + + + Solver backend + + + + + + + Select the constraint solver used for assembly solving + + + + Qt::Vertical diff --git a/src/Mod/Assembly/Preferences.py b/src/Mod/Assembly/Preferences.py index 5bb07ccea6..bfa5022dd6 100644 --- a/src/Mod/Assembly/Preferences.py +++ b/src/Mod/Assembly/Preferences.py @@ -40,13 +40,34 @@ class PreferencesPage: pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked()) pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked()) pref.SetInt("GroundFirstPart", self.form.groundFirstPart.currentIndex()) + idx = self.form.solverBackend.currentIndex() + solver_name = self.form.solverBackend.itemData(idx) or "" + pref.SetString("Solver", solver_name) def loadSettings(self): pref = preferences() - self.form.checkBoxEnableEscape.setChecked(pref.GetBool("LeaveEditWithEscape", True)) + self.form.checkBoxEnableEscape.setChecked( + pref.GetBool("LeaveEditWithEscape", True) + ) self.form.checkBoxSolverDebug.setChecked(pref.GetBool("LogSolverDebug", False)) self.form.groundFirstPart.clear() self.form.groundFirstPart.addItem(translate("Assembly", "Ask")) self.form.groundFirstPart.addItem(translate("Assembly", "Always")) self.form.groundFirstPart.addItem(translate("Assembly", "Never")) self.form.groundFirstPart.setCurrentIndex(pref.GetInt("GroundFirstPart", 0)) + + self.form.solverBackend.clear() + self.form.solverBackend.addItem(translate("Assembly", "Default"), "") + try: + import kcsolve + + for name in kcsolve.available(): + solver = kcsolve.load(name) + self.form.solverBackend.addItem(solver.name(), name) + except ImportError: + pass + current = pref.GetString("Solver", "") + for i in range(self.form.solverBackend.count()): + if self.form.solverBackend.itemData(i) == current: + self.form.solverBackend.setCurrentIndex(i) + break diff --git a/src/Mod/Assembly/TestAssemblyWorkbench.py b/src/Mod/Assembly/TestAssemblyWorkbench.py index 87d83297fc..3ca60190ab 100644 --- a/src/Mod/Assembly/TestAssemblyWorkbench.py +++ b/src/Mod/Assembly/TestAssemblyWorkbench.py @@ -30,9 +30,11 @@ from AssemblyTests.TestKCSolvePy import ( TestKCSolveTypes, # noqa: F401 TestPySolver, # noqa: F401 ) +from AssemblyTests.TestKindredSolverIntegration import TestKindredSolverIntegration from AssemblyTests.TestSolverIntegration import TestSolverIntegration # Use the modules so that code checkers don't complain (flake8) True if TestCore else False True if TestCommandInsertLink else False True if TestSolverIntegration else False +True if TestKindredSolverIntegration else False