feat(solver): KCSolve solver addon with assembly integration (#289)
Some checks failed
Build and Test / build (pull_request) Has been cancelled

Adds the Kindred constraint solver as a pluggable Assembly workbench
backend, covering phases 3d through 5 of the solver roadmap.

Phase 3d: SolveContext packing
- Pack/unpack SolveContext into .kc archive on document save

Solver addon (mods/solver):
- Phase 1: Expression DAG, Newton-Raphson + BFGS, 3 basic constraints
- Phase 2: Full constraint vocabulary — all 24 BaseJointKind types
- Phase 3: Graph decomposition for cluster-by-cluster solving
- Phase 4: Per-entity DOF diagnostics, overconstrained detection,
  half-space preference tracking, minimum-movement weighting
- Phase 5: _build_system extraction, diagnose(), drag protocol,
  joint limits warning

Assembly workbench integration:
- Preference-driven solver selection (reads Mod/Assembly/Solver param)
- Solver backend combo box in Assembly preferences UI
- resetSolver() on AssemblyObject for live preference switching
- Integration tests (TestKindredSolverIntegration.py)
- In-client console test script (console_test_phase5.py)
This commit is contained in:
forbes
2026-02-20 23:47:50 -06:00
parent 311b3ea4f1
commit 9b04a48a86
14 changed files with 508 additions and 8 deletions

4
.gitmodules vendored
View File

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

Submodule mods/solver added at adaa0f9a69

View File

@@ -144,14 +144,39 @@ void AssemblyObject::onChanged(const App::Property* prop)
// ── Solver integration ─────────────────────────────────────────────
void AssemblyObject::resetSolver()
{
solver_.reset();
}
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();
}
KCSolve::SolveContext AssemblyObject::getSolveContext()
{
partIdToObjs_.clear();
objToPartId_.clear();
auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) {
return {};
}
std::vector<App::DocumentObject*> joints = getJoints(false);
removeUnconnectedJoints(joints, groundedObjs);
return buildSolveContext(joints);
}
int AssemblyObject::solve(bool enableRedo, bool updateJCS)
{
ensureIdentityPlacements();

View File

@@ -98,10 +98,15 @@ public:
void postDrag();
void savePlacementsForUndo();
void undoSolve();
void resetSolver();
void clearUndo();
void exportAsASMT(std::string fileName);
/// Build the assembly constraint graph without solving.
/// Returns an empty SolveContext if no parts are grounded.
KCSolve::SolveContext getSolveContext();
bool validateNewPlacements();
void setNewPlacements();
static void redrawJointPlacements(std::vector<App::DocumentObject*> joints);

View File

@@ -4,10 +4,9 @@ from __future__ import annotations
from typing import Any, Final
from Base.Metadata import constmethod, export
from App.Part import Part
from App.DocumentObject import DocumentObject
from App.Part import Part
from Base.Metadata import constmethod, export
@export(Include="Mod/Assembly/App/AssemblyObject.h", Namespace="Assembly")
class AssemblyObject(Part):
@@ -119,7 +118,9 @@ class AssemblyObject(Part):
...
@constmethod
def isJointConnectingPartToGround(self, joint: DocumentObject, prop_name: str, /) -> Any:
def isJointConnectingPartToGround(
self, joint: DocumentObject, prop_name: str, /
) -> Any:
"""
Check if a joint is connecting a part to the ground.
@@ -153,6 +154,16 @@ class AssemblyObject(Part):
"""
...
@constmethod
def getSolveContext(self) -> dict:
"""Build the assembly constraint graph as a serializable dict.
Returns a dict matching kcsolve.SolveContext.to_dict() format,
or an empty dict if the assembly has no grounded parts.
Does NOT trigger a solve.
"""
...
@constmethod
def getDownstreamParts(
self, start_part: DocumentObject, joint_to_ignore: DocumentObject, /

View File

@@ -22,12 +22,160 @@
***************************************************************************/
// inclusion of the generated files (generated out of AssemblyObject.xml)
// inclusion of the generated files (generated out of AssemblyObject.xml)
#include "AssemblyObjectPy.h"
#include "AssemblyObjectPy.cpp"
#include <Mod/Assembly/Solver/SolverRegistry.h>
using namespace Assembly;
namespace
{
// ── Enum-to-string tables for dict serialization ───────────────────
// String values must match kcsolve_py.cpp py::enum_ .value() names exactly.
const char* baseJointKindStr(KCSolve::BaseJointKind k)
{
switch (k) {
case KCSolve::BaseJointKind::Coincident: return "Coincident";
case KCSolve::BaseJointKind::PointOnLine: return "PointOnLine";
case KCSolve::BaseJointKind::PointInPlane: return "PointInPlane";
case KCSolve::BaseJointKind::Concentric: return "Concentric";
case KCSolve::BaseJointKind::Tangent: return "Tangent";
case KCSolve::BaseJointKind::Planar: return "Planar";
case KCSolve::BaseJointKind::LineInPlane: return "LineInPlane";
case KCSolve::BaseJointKind::Parallel: return "Parallel";
case KCSolve::BaseJointKind::Perpendicular: return "Perpendicular";
case KCSolve::BaseJointKind::Angle: return "Angle";
case KCSolve::BaseJointKind::Fixed: return "Fixed";
case KCSolve::BaseJointKind::Revolute: return "Revolute";
case KCSolve::BaseJointKind::Cylindrical: return "Cylindrical";
case KCSolve::BaseJointKind::Slider: return "Slider";
case KCSolve::BaseJointKind::Ball: return "Ball";
case KCSolve::BaseJointKind::Screw: return "Screw";
case KCSolve::BaseJointKind::Universal: return "Universal";
case KCSolve::BaseJointKind::Gear: return "Gear";
case KCSolve::BaseJointKind::RackPinion: return "RackPinion";
case KCSolve::BaseJointKind::Cam: return "Cam";
case KCSolve::BaseJointKind::Slot: return "Slot";
case KCSolve::BaseJointKind::DistancePointPoint: return "DistancePointPoint";
case KCSolve::BaseJointKind::DistanceCylSph: return "DistanceCylSph";
case KCSolve::BaseJointKind::Custom: return "Custom";
}
return "Custom";
}
const char* limitKindStr(KCSolve::Constraint::Limit::Kind k)
{
switch (k) {
case KCSolve::Constraint::Limit::Kind::TranslationMin: return "TranslationMin";
case KCSolve::Constraint::Limit::Kind::TranslationMax: return "TranslationMax";
case KCSolve::Constraint::Limit::Kind::RotationMin: return "RotationMin";
case KCSolve::Constraint::Limit::Kind::RotationMax: return "RotationMax";
}
return "TranslationMin";
}
const char* motionKindStr(KCSolve::MotionDef::Kind k)
{
switch (k) {
case KCSolve::MotionDef::Kind::Rotational: return "Rotational";
case KCSolve::MotionDef::Kind::Translational: return "Translational";
case KCSolve::MotionDef::Kind::General: return "General";
}
return "Rotational";
}
// ── Python dict builders ───────────────────────────────────────────
// Layout matches solve_context_to_dict() in kcsolve_py.cpp exactly.
Py::Dict transformToDict(const KCSolve::Transform& t)
{
Py::Dict d;
d.setItem("position", Py::TupleN(
Py::Float(t.position[0]),
Py::Float(t.position[1]),
Py::Float(t.position[2])));
d.setItem("quaternion", Py::TupleN(
Py::Float(t.quaternion[0]),
Py::Float(t.quaternion[1]),
Py::Float(t.quaternion[2]),
Py::Float(t.quaternion[3])));
return d;
}
Py::Dict partToDict(const KCSolve::Part& p)
{
Py::Dict d;
d.setItem("id", Py::String(p.id));
d.setItem("placement", transformToDict(p.placement));
d.setItem("mass", Py::Float(p.mass));
d.setItem("grounded", Py::Boolean(p.grounded));
return d;
}
Py::Dict limitToDict(const KCSolve::Constraint::Limit& lim)
{
Py::Dict d;
d.setItem("kind", Py::String(limitKindStr(lim.kind)));
d.setItem("value", Py::Float(lim.value));
d.setItem("tolerance", Py::Float(lim.tolerance));
return d;
}
Py::Dict constraintToDict(const KCSolve::Constraint& c)
{
Py::Dict d;
d.setItem("id", Py::String(c.id));
d.setItem("part_i", Py::String(c.part_i));
d.setItem("marker_i", transformToDict(c.marker_i));
d.setItem("part_j", Py::String(c.part_j));
d.setItem("marker_j", transformToDict(c.marker_j));
d.setItem("type", Py::String(baseJointKindStr(c.type)));
Py::List params;
for (double v : c.params) {
params.append(Py::Float(v));
}
d.setItem("params", params);
Py::List lims;
for (const auto& l : c.limits) {
lims.append(limitToDict(l));
}
d.setItem("limits", lims);
d.setItem("activated", Py::Boolean(c.activated));
return d;
}
Py::Dict motionToDict(const KCSolve::MotionDef& m)
{
Py::Dict d;
d.setItem("kind", Py::String(motionKindStr(m.kind)));
d.setItem("joint_id", Py::String(m.joint_id));
d.setItem("marker_i", Py::String(m.marker_i));
d.setItem("marker_j", Py::String(m.marker_j));
d.setItem("rotation_expr", Py::String(m.rotation_expr));
d.setItem("translation_expr", Py::String(m.translation_expr));
return d;
}
Py::Dict simToDict(const KCSolve::SimulationParams& s)
{
Py::Dict d;
d.setItem("t_start", Py::Float(s.t_start));
d.setItem("t_end", Py::Float(s.t_end));
d.setItem("h_out", Py::Float(s.h_out));
d.setItem("h_min", Py::Float(s.h_min));
d.setItem("h_max", Py::Float(s.h_max));
d.setItem("error_tol", Py::Float(s.error_tol));
return d;
}
} // anonymous namespace
// returns a string which represents the object e.g. when printed in python
std::string AssemblyObjectPy::representation() const
{
@@ -243,3 +391,52 @@ PyObject* AssemblyObjectPy::getDownstreamParts(PyObject* args) const
return Py::new_reference_to(ret);
}
PyObject* AssemblyObjectPy::getSolveContext(PyObject* args) const
{
if (!PyArg_ParseTuple(args, "")) {
return nullptr;
}
PY_TRY
{
KCSolve::SolveContext ctx = getAssemblyObjectPtr()->getSolveContext();
// Empty context (no grounded parts) → return empty dict
if (ctx.parts.empty()) {
return Py::new_reference_to(Py::Dict());
}
Py::Dict d;
d.setItem("api_version", Py::Long(KCSolve::API_VERSION_MAJOR));
Py::List parts;
for (const auto& p : ctx.parts) {
parts.append(partToDict(p));
}
d.setItem("parts", parts);
Py::List constraints;
for (const auto& c : ctx.constraints) {
constraints.append(constraintToDict(c));
}
d.setItem("constraints", constraints);
Py::List motions;
for (const auto& m : ctx.motions) {
motions.append(motionToDict(m));
}
d.setItem("motions", motions);
if (ctx.simulation.has_value()) {
d.setItem("simulation", simToDict(*ctx.simulation));
}
else {
d.setItem("simulation", Py::None());
}
d.setItem("bundle_fixed", Py::Boolean(ctx.bundle_fixed));
return Py::new_reference_to(d);
}
PY_CATCH;
}

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

View File

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

View File

@@ -90,6 +90,24 @@ def _manifest_enrich_hook(doc, filename, entries):
register_pre_reinject(_manifest_enrich_hook)
def _solver_context_hook(doc, filename, entries):
"""Pack solver context into silo/solver/context.json for assemblies."""
try:
for obj in doc.Objects:
if obj.TypeId == "Assembly::AssemblyObject":
ctx = obj.getSolveContext()
if ctx: # non-empty means we have grounded parts
entries["silo/solver/context.json"] = (
json.dumps(ctx, indent=2) + "\n"
).encode("utf-8")
break # one assembly per document
except Exception as exc:
FreeCAD.Console.PrintWarning(f"kc_format: solver context hook failed: {exc}\n")
register_pre_reinject(_solver_context_hook)
KC_VERSION = "1.0"

View File

@@ -50,6 +50,12 @@ _KNOWN_ENTRIES = [
"Dependencies",
("links", lambda v: isinstance(v, list) and len(v) > 0),
),
(
"silo/solver/context.json",
"SiloSolverContext",
"Solver Context",
("parts", lambda v: isinstance(v, list) and len(v) > 0),
),
]