Merge pull request 'feat(solver): KCSolve solver addon with assembly integration (#289)' (#303) from feat/solver-context-packing into main
Reviewed-on: #303
This commit was merged in pull request #303.
This commit is contained in:
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -18,3 +18,7 @@
|
||||
path = mods/silo
|
||||
url = https://git.kindred-systems.com/kindred/silo-mod.git
|
||||
branch = main
|
||||
[submodule "mods/solver"]
|
||||
path = mods/solver
|
||||
url = https://git.kindred-systems.com/kindred/solver.git
|
||||
branch = main
|
||||
|
||||
1
mods/solver
Submodule
1
mods/solver
Submodule
Submodule mods/solver added at adaa0f9a69
@@ -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();
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ public:
|
||||
void postDrag();
|
||||
void savePlacementsForUndo();
|
||||
void undoSolve();
|
||||
void resetSolver() { solver_.reset(); }
|
||||
void clearUndo();
|
||||
|
||||
void exportAsASMT(std::string fileName);
|
||||
|
||||
180
src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py
Normal file
180
src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py
Normal 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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,3 +84,18 @@ install(
|
||||
DESTINATION
|
||||
mods/sdk
|
||||
)
|
||||
|
||||
# Install Kindred Solver addon
|
||||
install(
|
||||
DIRECTORY
|
||||
${CMAKE_SOURCE_DIR}/mods/solver/kindred_solver
|
||||
DESTINATION
|
||||
mods/solver
|
||||
)
|
||||
install(
|
||||
FILES
|
||||
${CMAKE_SOURCE_DIR}/mods/solver/package.xml
|
||||
${CMAKE_SOURCE_DIR}/mods/solver/Init.py
|
||||
DESTINATION
|
||||
mods/solver
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user