feat(solver): Phase 5 assembly integration — plug Kindred solver into Assembly workbench (#289 phase 5)

- 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
This commit is contained in:
forbes
2026-02-20 23:33:36 -06:00
parent b157d1522b
commit 6ee90727fb
8 changed files with 226 additions and 3 deletions

View File

@@ -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();
}

View File

@@ -98,6 +98,7 @@ public:
void postDrag();
void savePlacementsForUndo();
void undoSolve();
void resetSolver() { solver_.reset(); }
void clearUndo();
void exportAsASMT(std::string fileName);

View File

@@ -0,0 +1,180 @@
# 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/>. *
# *
# ***************************************************************************/
"""
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",
)

View File

@@ -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

View File

@@ -84,6 +84,20 @@ The files are named "runPreDrag.asmt" and "dragging.log" and are located in the
</spacer>
</item>
<item row="3" column="0">
<widget class="QLabel" name="solverBackendLabel">
<property name="text">
<string>Solver backend</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QComboBox" name="solverBackend">
<property name="toolTip">
<string>Select the constraint solver used for assembly solving</string>
</property>
</widget>
</item>
<item row="4" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>

View File

@@ -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

View File

@@ -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