Compare commits

...

12 Commits

Author SHA1 Message Date
forbes
9b04a48a86 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)
2026-02-21 07:02:54 -06:00
311b3ea4f1 Merge pull request 'fix(gui): complete toolbar whitelists in EditingContextResolver' (#301) from fix/toolbar-context-whitelists into main
All checks were successful
Build and Test / build (push) Successful in 30m19s
Reviewed-on: #301
2026-02-20 18:14:11 +00:00
forbes
686d8699c9 fix(gui): complete toolbar whitelists in EditingContextResolver
All checks were successful
Build and Test / build (pull_request) Successful in 29m59s
The EditingContextResolver controls toolbar visibility via explicit
whitelists per editing context. Several contexts had incomplete lists,
causing workbench toolbars to be missing compared to base FreeCAD.

Changes:

partdesign.feature (priority 40):
  - Add 'Sketcher' toolbar so users can create new sketches from an
    active Body with features

partdesign.body (priority 30):
  - Add Modeling, Dress-Up, and Transformation toolbars (previously
    only showed Helper + Sketcher)

partdesign.workbench (priority 20):
  - Add Modeling, Dress-Up, and Transformation toolbars (same as body)

sketcher.workbench (priority 20):
  - Add Geometries, Constraints, B-Spline Tools, Visual Helpers
    (previously only showed Sketcher + Sketcher Tools)

assembly.idle (priority 30):
  - Add 'Assembly Joints' and 'Assembly Management' toolbars

assembly.workbench (priority 20):
  - Add 'Assembly Joints' and 'Assembly Management' toolbars

No changes to sketcher.edit or assembly.edit contexts — those were
already correct.
2026-02-20 12:13:23 -06:00
7dc7aac935 Merge pull request 'docs(solver): KCSolve architecture, API reference, and server specification' (#300) from docs/solver-spec-update into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #300
2026-02-20 18:04:12 +00:00
84f83a9d18 Merge branch 'main' into docs/solver-spec-update
All checks were successful
Build and Test / build (pull_request) Successful in 30m48s
2026-02-20 18:03:53 +00:00
forbes
64fbc167f7 docs(solver): server specification for KCSolve solver service
All checks were successful
Build and Test / build (pull_request) Successful in 30m11s
Comprehensive specification covering:
- Architecture: solver module integrated into Silo's job queue system
- Data model: JSON schemas for SolveContext and SolveResult transport
- REST API: submit, status, list, cancel endpoints under /api/solver/
- SSE events: solver.created, solver.progress, solver.completed, solver.failed
- Runner integration: standalone kcsolve execution, capability reporting
- Job definitions: manual solve, commit-time validation, kinematic simulation
- SolveContext extraction: headless Create and .kc archive packing
- Database schema: solver_results table with per-revision result caching
- Configuration: server and runner config patterns
- Security: input validation, runner isolation, authentication
- Client SDK: Python client and Create workbench integration sketches
- Implementation plan: Phase 3a-3e breakdown
2026-02-20 12:02:54 -06:00
forbes
315ac2a25d 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).
2026-02-20 12:02:54 -06:00
forbes
98b0f72352 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
2026-02-20 12:02:54 -06:00
Kindred Bot
c0ee4ecccf docs: sync Silo server documentation
Auto-synced from kindred/silo main branch.
2026-02-20 12:02:54 -06:00
1ed73e3eb0 Merge pull request 'feat(kcsolve): JSON serialization for all solver types (Phase 3a)' (#299) from feat/kcsolve-serialization into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 45s
Build and Test / build (push) Has been cancelled
Reviewed-on: #299
2026-02-20 18:01:30 +00:00
forbes
7e766a228e feat(kcsolve): add to_dict()/from_dict() JSON serialization for all types
All checks were successful
Build and Test / build (pull_request) Successful in 31m19s
Phase 3a of the solver server integration: add dict/JSON serialization
to all KCSolve pybind11 types so SolveContext and SolveResult can be
transported as JSON between the Create client, Silo server, and solver
runners.

Implementation:
- Constexpr enum string mapping tables for all 5 enums (BaseJointKind,
  SolveStatus, DiagnosticKind, MotionKind, LimitKind) with template
  bidirectional lookup helpers
- File-local to_dict/from_dict conversion functions for all 10 types
  (Transform, Part, Constraint::Limit, Constraint, MotionDef,
  SimulationParams, SolveContext, ConstraintDiagnostic,
  SolveResult::PartResult, SolveResult)
- .def("to_dict") and .def_static("from_dict") on every py::class_<>
  binding chain

Serialization details per SOLVER.md §3:
- SolveContext.to_dict() includes api_version field
- SolveContext.from_dict() validates api_version, raises ValueError on
  mismatch
- Enums serialize as strings matching pybind11 .value() names
- Transform: {position: [x,y,z], quaternion: [w,x,y,z]}
- Optional simulation serializes as None/null
- Pure pybind11 py::dict construction, no new dependencies

Tests: 16 new tests in TestKCSolveSerialization covering round-trips for
all types, all 24 BaseJointKind values, all 4 SolveStatus values,
json.dumps/loads stdlib round-trip, and error cases (missing key,
invalid enum, bad array length, wrong api_version).
2026-02-20 11:58:18 -06:00
b02bcbfe46 Merge pull request 'feat(kcsolve): pybind11 bindings and Python solver support' (#298) from feat/solver-api-types into main
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 56s
Build and Test / build (push) Successful in 29m41s
Sync Silo Server Docs / sync (push) Successful in 48s
Reviewed-on: #298
2026-02-20 01:10:00 +00:00
17 changed files with 1296 additions and 24 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

@@ -267,7 +267,8 @@ void EditingContextResolver::registerBuiltinContexts()
{QStringLiteral("Part Design Helper Features"),
QStringLiteral("Part Design Modeling Features"),
QStringLiteral("Part Design Dress-Up Features"),
QStringLiteral("Part Design Transformation Features")},
QStringLiteral("Part Design Transformation Features"),
QStringLiteral("Sketcher")},
/*.priority =*/40,
/*.match =*/
[]() {
@@ -292,7 +293,11 @@ void EditingContextResolver::registerBuiltinContexts()
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
/*.toolbars =*/
{QStringLiteral("Part Design Helper Features"), QStringLiteral("Sketcher")},
{QStringLiteral("Part Design Helper Features"),
QStringLiteral("Part Design Modeling Features"),
QStringLiteral("Part Design Dress-Up Features"),
QStringLiteral("Part Design Transformation Features"),
QStringLiteral("Sketcher")},
/*.priority =*/30,
/*.match =*/
[]() {
@@ -307,7 +312,9 @@ void EditingContextResolver::registerBuiltinContexts()
/*.labelTemplate =*/QStringLiteral("Assembly: {name}"),
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
/*.toolbars =*/
{QStringLiteral("Assembly")},
{QStringLiteral("Assembly"),
QStringLiteral("Assembly Joints"),
QStringLiteral("Assembly Management")},
/*.priority =*/30,
/*.match =*/
[]() {
@@ -340,7 +347,11 @@ void EditingContextResolver::registerBuiltinContexts()
/*.labelTemplate =*/QStringLiteral("Part Design"),
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
/*.toolbars =*/
{QStringLiteral("Part Design Helper Features"), QStringLiteral("Sketcher")},
{QStringLiteral("Part Design Helper Features"),
QStringLiteral("Part Design Modeling Features"),
QStringLiteral("Part Design Dress-Up Features"),
QStringLiteral("Part Design Transformation Features"),
QStringLiteral("Sketcher")},
/*.priority =*/20,
/*.match =*/
[]() {
@@ -353,7 +364,12 @@ void EditingContextResolver::registerBuiltinContexts()
/*.labelTemplate =*/QStringLiteral("Sketcher"),
/*.color =*/QLatin1String(CatppuccinMocha::Green),
/*.toolbars =*/
{QStringLiteral("Sketcher"), QStringLiteral("Sketcher Tools")},
{QStringLiteral("Sketcher"),
QStringLiteral("Sketcher Tools"),
QStringLiteral("Geometries"),
QStringLiteral("Constraints"),
QStringLiteral("B-Spline Tools"),
QStringLiteral("Visual Helpers")},
/*.priority =*/20,
/*.match =*/
[]() {
@@ -366,7 +382,9 @@ void EditingContextResolver::registerBuiltinContexts()
/*.labelTemplate =*/QStringLiteral("Assembly"),
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
/*.toolbars =*/
{QStringLiteral("Assembly")},
{QStringLiteral("Assembly"),
QStringLiteral("Assembly Joints"),
QStringLiteral("Assembly Management")},
/*.priority =*/20,
/*.match =*/
[]() {

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

@@ -21,13 +21,161 @@
* *
***************************************************************************/
// 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

@@ -149,6 +149,289 @@ class TestKCSolveRegistry(unittest.TestCase):
self.assertEqual(kcsolve.get_default(), original)
class TestKCSolveSerialization(unittest.TestCase):
"""Verify to_dict() / from_dict() round-trip on all KCSolve types."""
def test_transform_round_trip(self):
import kcsolve
t = kcsolve.Transform()
t.position = [1.0, 2.0, 3.0]
t.quaternion = [0.5, 0.5, 0.5, 0.5]
d = t.to_dict()
self.assertEqual(list(d["position"]), [1.0, 2.0, 3.0])
self.assertEqual(list(d["quaternion"]), [0.5, 0.5, 0.5, 0.5])
t2 = kcsolve.Transform.from_dict(d)
self.assertEqual(list(t2.position), [1.0, 2.0, 3.0])
self.assertEqual(list(t2.quaternion), [0.5, 0.5, 0.5, 0.5])
def test_transform_identity_round_trip(self):
import kcsolve
t = kcsolve.Transform.identity()
t2 = kcsolve.Transform.from_dict(t.to_dict())
self.assertEqual(list(t2.position), [0.0, 0.0, 0.0])
self.assertEqual(list(t2.quaternion), [1.0, 0.0, 0.0, 0.0])
def test_part_round_trip(self):
import kcsolve
p = kcsolve.Part()
p.id = "box"
p.mass = 2.5
p.grounded = True
p.placement = kcsolve.Transform.identity()
d = p.to_dict()
self.assertEqual(d["id"], "box")
self.assertAlmostEqual(d["mass"], 2.5)
self.assertTrue(d["grounded"])
p2 = kcsolve.Part.from_dict(d)
self.assertEqual(p2.id, "box")
self.assertAlmostEqual(p2.mass, 2.5)
self.assertTrue(p2.grounded)
def test_constraint_with_limits_round_trip(self):
import kcsolve
c = kcsolve.Constraint()
c.id = "Joint001"
c.part_i = "part1"
c.part_j = "part2"
c.type = kcsolve.BaseJointKind.Revolute
c.params = [1.5, 2.5]
lim = kcsolve.Constraint.Limit()
lim.kind = kcsolve.LimitKind.RotationMin
lim.value = -3.14
lim.tolerance = 0.01
c.limits = [lim]
d = c.to_dict()
self.assertEqual(d["type"], "Revolute")
self.assertEqual(len(d["limits"]), 1)
self.assertEqual(d["limits"][0]["kind"], "RotationMin")
c2 = kcsolve.Constraint.from_dict(d)
self.assertEqual(c2.type, kcsolve.BaseJointKind.Revolute)
self.assertEqual(len(c2.limits), 1)
self.assertEqual(c2.limits[0].kind, kcsolve.LimitKind.RotationMin)
self.assertAlmostEqual(c2.limits[0].value, -3.14)
def test_solve_context_full_round_trip(self):
import kcsolve
ctx = kcsolve.SolveContext()
p = kcsolve.Part()
p.id = "box"
p.grounded = True
ctx.parts = [p]
c = kcsolve.Constraint()
c.id = "J1"
c.part_i = "box"
c.part_j = "cyl"
c.type = kcsolve.BaseJointKind.Fixed
ctx.constraints = [c]
ctx.bundle_fixed = True
d = ctx.to_dict()
self.assertEqual(d["api_version"], kcsolve.API_VERSION_MAJOR)
self.assertEqual(len(d["parts"]), 1)
self.assertEqual(len(d["constraints"]), 1)
self.assertTrue(d["bundle_fixed"])
ctx2 = kcsolve.SolveContext.from_dict(d)
self.assertEqual(ctx2.parts[0].id, "box")
self.assertTrue(ctx2.parts[0].grounded)
self.assertEqual(ctx2.constraints[0].type, kcsolve.BaseJointKind.Fixed)
self.assertTrue(ctx2.bundle_fixed)
def test_solve_context_with_simulation(self):
import kcsolve
ctx = kcsolve.SolveContext()
ctx.parts = []
ctx.constraints = []
sim = kcsolve.SimulationParams()
sim.t_start = 0.0
sim.t_end = 10.0
sim.h_out = 0.01
ctx.simulation = sim
d = ctx.to_dict()
self.assertIsNotNone(d["simulation"])
self.assertAlmostEqual(d["simulation"]["t_end"], 10.0)
ctx2 = kcsolve.SolveContext.from_dict(d)
self.assertIsNotNone(ctx2.simulation)
self.assertAlmostEqual(ctx2.simulation.t_end, 10.0)
def test_solve_context_simulation_null(self):
import kcsolve
ctx = kcsolve.SolveContext()
ctx.parts = []
ctx.constraints = []
ctx.simulation = None
d = ctx.to_dict()
self.assertIsNone(d["simulation"])
ctx2 = kcsolve.SolveContext.from_dict(d)
self.assertIsNone(ctx2.simulation)
def test_solve_result_round_trip(self):
import kcsolve
r = kcsolve.SolveResult()
r.status = kcsolve.SolveStatus.Success
r.dof = 6
pr = kcsolve.SolveResult.PartResult()
pr.id = "box"
pr.placement = kcsolve.Transform.identity()
r.placements = [pr]
diag = kcsolve.ConstraintDiagnostic()
diag.constraint_id = "J1"
diag.kind = kcsolve.DiagnosticKind.Redundant
diag.detail = "over-constrained"
r.diagnostics = [diag]
r.num_frames = 100
d = r.to_dict()
self.assertEqual(d["status"], "Success")
self.assertEqual(d["dof"], 6)
self.assertEqual(d["num_frames"], 100)
self.assertEqual(len(d["placements"]), 1)
self.assertEqual(len(d["diagnostics"]), 1)
r2 = kcsolve.SolveResult.from_dict(d)
self.assertEqual(r2.status, kcsolve.SolveStatus.Success)
self.assertEqual(r2.dof, 6)
self.assertEqual(r2.num_frames, 100)
self.assertEqual(r2.placements[0].id, "box")
self.assertEqual(r2.diagnostics[0].kind, kcsolve.DiagnosticKind.Redundant)
def test_motion_def_round_trip(self):
import kcsolve
m = kcsolve.MotionDef()
m.kind = kcsolve.MotionKind.Rotational
m.joint_id = "J1"
m.marker_i = "part1"
m.marker_j = "part2"
m.rotation_expr = "2*pi*time"
m.translation_expr = ""
d = m.to_dict()
self.assertEqual(d["kind"], "Rotational")
self.assertEqual(d["joint_id"], "J1")
m2 = kcsolve.MotionDef.from_dict(d)
self.assertEqual(m2.kind, kcsolve.MotionKind.Rotational)
self.assertEqual(m2.rotation_expr, "2*pi*time")
def test_all_base_joint_kinds_round_trip(self):
import kcsolve
all_kinds = [
"Coincident",
"PointOnLine",
"PointInPlane",
"Concentric",
"Tangent",
"Planar",
"LineInPlane",
"Parallel",
"Perpendicular",
"Angle",
"Fixed",
"Revolute",
"Cylindrical",
"Slider",
"Ball",
"Screw",
"Universal",
"Gear",
"RackPinion",
"Cam",
"Slot",
"DistancePointPoint",
"DistanceCylSph",
"Custom",
]
for name in all_kinds:
c = kcsolve.Constraint()
c.id = "test"
c.part_i = "a"
c.part_j = "b"
c.type = getattr(kcsolve.BaseJointKind, name)
d = c.to_dict()
self.assertEqual(d["type"], name)
c2 = kcsolve.Constraint.from_dict(d)
self.assertEqual(c2.type, getattr(kcsolve.BaseJointKind, name))
def test_all_solve_statuses_round_trip(self):
import kcsolve
for name in ("Success", "Failed", "InvalidFlip", "NoGroundedParts"):
r = kcsolve.SolveResult()
r.status = getattr(kcsolve.SolveStatus, name)
d = r.to_dict()
self.assertEqual(d["status"], name)
r2 = kcsolve.SolveResult.from_dict(d)
self.assertEqual(r2.status, getattr(kcsolve.SolveStatus, name))
def test_json_stdlib_round_trip(self):
import json
import kcsolve
ctx = kcsolve.SolveContext()
p = kcsolve.Part()
p.id = "box"
p.grounded = True
ctx.parts = [p]
ctx.constraints = []
d = ctx.to_dict()
json_str = json.dumps(d)
d2 = json.loads(json_str)
ctx2 = kcsolve.SolveContext.from_dict(d2)
self.assertEqual(ctx2.parts[0].id, "box")
def test_from_dict_missing_required_key(self):
import kcsolve
with self.assertRaises(KeyError):
kcsolve.Part.from_dict({"mass": 1.0, "grounded": False})
def test_from_dict_invalid_enum_string(self):
import kcsolve
d = {
"id": "J1",
"part_i": "a",
"part_j": "b",
"type": "Bogus",
"marker_i": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]},
"marker_j": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]},
}
with self.assertRaises(ValueError):
kcsolve.Constraint.from_dict(d)
def test_from_dict_bad_position_length(self):
import kcsolve
with self.assertRaises(ValueError):
kcsolve.Transform.from_dict(
{
"position": [1.0, 2.0],
"quaternion": [1, 0, 0, 0],
}
)
def test_from_dict_bad_api_version(self):
import kcsolve
d = {
"api_version": 99,
"parts": [],
"constraints": [],
}
with self.assertRaises(ValueError):
kcsolve.SolveContext.from_dict(d)
class TestPySolver(unittest.TestCase):
"""Verify Python IKCSolver subclassing and registration."""

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

@@ -31,6 +31,7 @@
#include "PyIKCSolver.h"
#include <cstddef>
#include <memory>
#include <string>
@@ -38,6 +39,456 @@ namespace py = pybind11;
using namespace KCSolve;
// ── Enum string mapping ────────────────────────────────────────────
//
// Constexpr tables for bidirectional enum <-> string conversion.
// String values match the py::enum_ .value("Name", ...) names exactly,
// which is also the JSON wire format specified in SOLVER.md §3.
namespace
{
template<typename E>
struct EnumEntry
{
E value;
const char* name;
};
static constexpr EnumEntry<BaseJointKind> kBaseJointKindEntries[] = {
{BaseJointKind::Coincident, "Coincident"},
{BaseJointKind::PointOnLine, "PointOnLine"},
{BaseJointKind::PointInPlane, "PointInPlane"},
{BaseJointKind::Concentric, "Concentric"},
{BaseJointKind::Tangent, "Tangent"},
{BaseJointKind::Planar, "Planar"},
{BaseJointKind::LineInPlane, "LineInPlane"},
{BaseJointKind::Parallel, "Parallel"},
{BaseJointKind::Perpendicular, "Perpendicular"},
{BaseJointKind::Angle, "Angle"},
{BaseJointKind::Fixed, "Fixed"},
{BaseJointKind::Revolute, "Revolute"},
{BaseJointKind::Cylindrical, "Cylindrical"},
{BaseJointKind::Slider, "Slider"},
{BaseJointKind::Ball, "Ball"},
{BaseJointKind::Screw, "Screw"},
{BaseJointKind::Universal, "Universal"},
{BaseJointKind::Gear, "Gear"},
{BaseJointKind::RackPinion, "RackPinion"},
{BaseJointKind::Cam, "Cam"},
{BaseJointKind::Slot, "Slot"},
{BaseJointKind::DistancePointPoint, "DistancePointPoint"},
{BaseJointKind::DistanceCylSph, "DistanceCylSph"},
{BaseJointKind::Custom, "Custom"},
};
static constexpr EnumEntry<SolveStatus> kSolveStatusEntries[] = {
{SolveStatus::Success, "Success"},
{SolveStatus::Failed, "Failed"},
{SolveStatus::InvalidFlip, "InvalidFlip"},
{SolveStatus::NoGroundedParts, "NoGroundedParts"},
};
static constexpr EnumEntry<ConstraintDiagnostic::Kind> kDiagnosticKindEntries[] = {
{ConstraintDiagnostic::Kind::Redundant, "Redundant"},
{ConstraintDiagnostic::Kind::Conflicting, "Conflicting"},
{ConstraintDiagnostic::Kind::PartiallyRedundant, "PartiallyRedundant"},
{ConstraintDiagnostic::Kind::Malformed, "Malformed"},
};
static constexpr EnumEntry<MotionDef::Kind> kMotionKindEntries[] = {
{MotionDef::Kind::Rotational, "Rotational"},
{MotionDef::Kind::Translational, "Translational"},
{MotionDef::Kind::General, "General"},
};
static constexpr EnumEntry<Constraint::Limit::Kind> kLimitKindEntries[] = {
{Constraint::Limit::Kind::TranslationMin, "TranslationMin"},
{Constraint::Limit::Kind::TranslationMax, "TranslationMax"},
{Constraint::Limit::Kind::RotationMin, "RotationMin"},
{Constraint::Limit::Kind::RotationMax, "RotationMax"},
};
template<typename E, std::size_t N>
const char* enum_to_str(E val, const EnumEntry<E> (&table)[N])
{
for (std::size_t i = 0; i < N; ++i) {
if (table[i].value == val) {
return table[i].name;
}
}
throw py::value_error("Unknown enum value: " + std::to_string(static_cast<int>(val)));
}
template<typename E, std::size_t N>
E str_to_enum(const std::string& name, const EnumEntry<E> (&table)[N],
const char* enum_type_name)
{
for (std::size_t i = 0; i < N; ++i) {
if (name == table[i].name) {
return table[i].value;
}
}
throw py::value_error(
std::string("Invalid ") + enum_type_name + " value: '" + name + "'");
}
// ── Dict conversion helpers ────────────────────────────────────────
//
// Standalone functions for each type so SolveContext/SolveResult can
// reuse them without duplicating serialization logic.
py::dict transform_to_dict(const Transform& t)
{
py::dict d;
d["position"] = py::make_tuple(t.position[0], t.position[1], t.position[2]);
d["quaternion"] = py::make_tuple(
t.quaternion[0], t.quaternion[1], t.quaternion[2], t.quaternion[3]);
return d;
}
Transform transform_from_dict(const py::dict& d)
{
Transform t;
auto pos = d["position"].cast<py::sequence>();
if (py::len(pos) != 3) {
throw py::value_error("position must have exactly 3 elements");
}
for (int i = 0; i < 3; ++i) {
t.position[static_cast<std::size_t>(i)] = pos[i].cast<double>();
}
auto quat = d["quaternion"].cast<py::sequence>();
if (py::len(quat) != 4) {
throw py::value_error("quaternion must have exactly 4 elements");
}
for (int i = 0; i < 4; ++i) {
t.quaternion[static_cast<std::size_t>(i)] = quat[i].cast<double>();
}
return t;
}
py::dict part_to_dict(const Part& p)
{
py::dict d;
d["id"] = p.id;
d["placement"] = transform_to_dict(p.placement);
d["mass"] = p.mass;
d["grounded"] = p.grounded;
return d;
}
Part part_from_dict(const py::dict& d)
{
Part p;
p.id = d["id"].cast<std::string>();
p.placement = transform_from_dict(d["placement"].cast<py::dict>());
if (d.contains("mass")) {
p.mass = d["mass"].cast<double>();
}
if (d.contains("grounded")) {
p.grounded = d["grounded"].cast<bool>();
}
return p;
}
py::dict limit_to_dict(const Constraint::Limit& lim)
{
py::dict d;
d["kind"] = enum_to_str(lim.kind, kLimitKindEntries);
d["value"] = lim.value;
d["tolerance"] = lim.tolerance;
return d;
}
Constraint::Limit limit_from_dict(const py::dict& d)
{
Constraint::Limit lim;
lim.kind = str_to_enum(d["kind"].cast<std::string>(),
kLimitKindEntries, "LimitKind");
lim.value = d["value"].cast<double>();
if (d.contains("tolerance")) {
lim.tolerance = d["tolerance"].cast<double>();
}
return lim;
}
py::dict constraint_to_dict(const Constraint& c)
{
py::dict d;
d["id"] = c.id;
d["part_i"] = c.part_i;
d["marker_i"] = transform_to_dict(c.marker_i);
d["part_j"] = c.part_j;
d["marker_j"] = transform_to_dict(c.marker_j);
d["type"] = enum_to_str(c.type, kBaseJointKindEntries);
d["params"] = py::cast(c.params);
py::list lims;
for (const auto& lim : c.limits) {
lims.append(limit_to_dict(lim));
}
d["limits"] = lims;
d["activated"] = c.activated;
return d;
}
Constraint constraint_from_dict(const py::dict& d)
{
Constraint c;
c.id = d["id"].cast<std::string>();
c.part_i = d["part_i"].cast<std::string>();
c.marker_i = transform_from_dict(d["marker_i"].cast<py::dict>());
c.part_j = d["part_j"].cast<std::string>();
c.marker_j = transform_from_dict(d["marker_j"].cast<py::dict>());
c.type = str_to_enum(d["type"].cast<std::string>(),
kBaseJointKindEntries, "BaseJointKind");
if (d.contains("params")) {
c.params = d["params"].cast<std::vector<double>>();
}
if (d.contains("limits")) {
for (auto item : d["limits"]) {
c.limits.push_back(limit_from_dict(item.cast<py::dict>()));
}
}
if (d.contains("activated")) {
c.activated = d["activated"].cast<bool>();
}
return c;
}
py::dict motion_to_dict(const MotionDef& m)
{
py::dict d;
d["kind"] = enum_to_str(m.kind, kMotionKindEntries);
d["joint_id"] = m.joint_id;
d["marker_i"] = m.marker_i;
d["marker_j"] = m.marker_j;
d["rotation_expr"] = m.rotation_expr;
d["translation_expr"] = m.translation_expr;
return d;
}
MotionDef motion_from_dict(const py::dict& d)
{
MotionDef m;
m.kind = str_to_enum(d["kind"].cast<std::string>(),
kMotionKindEntries, "MotionKind");
m.joint_id = d["joint_id"].cast<std::string>();
if (d.contains("marker_i")) {
m.marker_i = d["marker_i"].cast<std::string>();
}
if (d.contains("marker_j")) {
m.marker_j = d["marker_j"].cast<std::string>();
}
if (d.contains("rotation_expr")) {
m.rotation_expr = d["rotation_expr"].cast<std::string>();
}
if (d.contains("translation_expr")) {
m.translation_expr = d["translation_expr"].cast<std::string>();
}
return m;
}
py::dict sim_to_dict(const SimulationParams& s)
{
py::dict d;
d["t_start"] = s.t_start;
d["t_end"] = s.t_end;
d["h_out"] = s.h_out;
d["h_min"] = s.h_min;
d["h_max"] = s.h_max;
d["error_tol"] = s.error_tol;
return d;
}
SimulationParams sim_from_dict(const py::dict& d)
{
SimulationParams s;
if (d.contains("t_start")) {
s.t_start = d["t_start"].cast<double>();
}
if (d.contains("t_end")) {
s.t_end = d["t_end"].cast<double>();
}
if (d.contains("h_out")) {
s.h_out = d["h_out"].cast<double>();
}
if (d.contains("h_min")) {
s.h_min = d["h_min"].cast<double>();
}
if (d.contains("h_max")) {
s.h_max = d["h_max"].cast<double>();
}
if (d.contains("error_tol")) {
s.error_tol = d["error_tol"].cast<double>();
}
return s;
}
py::dict diagnostic_to_dict(const ConstraintDiagnostic& diag)
{
py::dict d;
d["constraint_id"] = diag.constraint_id;
d["kind"] = enum_to_str(diag.kind, kDiagnosticKindEntries);
d["detail"] = diag.detail;
return d;
}
ConstraintDiagnostic diagnostic_from_dict(const py::dict& d)
{
ConstraintDiagnostic diag;
diag.constraint_id = d["constraint_id"].cast<std::string>();
diag.kind = str_to_enum(d["kind"].cast<std::string>(),
kDiagnosticKindEntries, "DiagnosticKind");
if (d.contains("detail")) {
diag.detail = d["detail"].cast<std::string>();
}
return diag;
}
py::dict part_result_to_dict(const SolveResult::PartResult& pr)
{
py::dict d;
d["id"] = pr.id;
d["placement"] = transform_to_dict(pr.placement);
return d;
}
SolveResult::PartResult part_result_from_dict(const py::dict& d)
{
SolveResult::PartResult pr;
pr.id = d["id"].cast<std::string>();
pr.placement = transform_from_dict(d["placement"].cast<py::dict>());
return pr;
}
py::dict solve_context_to_dict(const SolveContext& ctx)
{
py::dict d;
d["api_version"] = API_VERSION_MAJOR;
py::list parts;
for (const auto& p : ctx.parts) {
parts.append(part_to_dict(p));
}
d["parts"] = parts;
py::list constraints;
for (const auto& c : ctx.constraints) {
constraints.append(constraint_to_dict(c));
}
d["constraints"] = constraints;
py::list motions;
for (const auto& m : ctx.motions) {
motions.append(motion_to_dict(m));
}
d["motions"] = motions;
if (ctx.simulation.has_value()) {
d["simulation"] = sim_to_dict(*ctx.simulation);
}
else {
d["simulation"] = py::none();
}
d["bundle_fixed"] = ctx.bundle_fixed;
return d;
}
SolveContext solve_context_from_dict(const py::dict& d)
{
SolveContext ctx;
if (d.contains("api_version")) {
int v = d["api_version"].cast<int>();
if (v != API_VERSION_MAJOR) {
throw py::value_error(
"Unsupported api_version " + std::to_string(v)
+ ", expected " + std::to_string(API_VERSION_MAJOR));
}
}
for (auto item : d["parts"]) {
ctx.parts.push_back(part_from_dict(item.cast<py::dict>()));
}
for (auto item : d["constraints"]) {
ctx.constraints.push_back(constraint_from_dict(item.cast<py::dict>()));
}
if (d.contains("motions")) {
for (auto item : d["motions"]) {
ctx.motions.push_back(motion_from_dict(item.cast<py::dict>()));
}
}
if (d.contains("simulation") && !d["simulation"].is_none()) {
ctx.simulation = sim_from_dict(d["simulation"].cast<py::dict>());
}
if (d.contains("bundle_fixed")) {
ctx.bundle_fixed = d["bundle_fixed"].cast<bool>();
}
return ctx;
}
py::dict solve_result_to_dict(const SolveResult& r)
{
py::dict d;
d["status"] = enum_to_str(r.status, kSolveStatusEntries);
py::list placements;
for (const auto& pr : r.placements) {
placements.append(part_result_to_dict(pr));
}
d["placements"] = placements;
d["dof"] = r.dof;
py::list diagnostics;
for (const auto& diag : r.diagnostics) {
diagnostics.append(diagnostic_to_dict(diag));
}
d["diagnostics"] = diagnostics;
d["num_frames"] = r.num_frames;
return d;
}
SolveResult solve_result_from_dict(const py::dict& d)
{
SolveResult r;
r.status = str_to_enum(d["status"].cast<std::string>(),
kSolveStatusEntries, "SolveStatus");
if (d.contains("placements")) {
for (auto item : d["placements"]) {
r.placements.push_back(part_result_from_dict(item.cast<py::dict>()));
}
}
if (d.contains("dof")) {
r.dof = d["dof"].cast<int>();
}
if (d.contains("diagnostics")) {
for (auto item : d["diagnostics"]) {
r.diagnostics.push_back(diagnostic_from_dict(item.cast<py::dict>()));
}
}
if (d.contains("num_frames")) {
r.num_frames = d["num_frames"].cast<std::size_t>();
}
return r;
}
} // anonymous namespace
// ── PySolverHolder ─────────────────────────────────────────────────
//
// Wraps a Python IKCSolver subclass instance so it can live inside a
@@ -216,14 +667,18 @@ PYBIND11_MODULE(kcsolve, m)
+ std::to_string(t.position[0]) + ", "
+ std::to_string(t.position[1]) + ", "
+ std::to_string(t.position[2]) + "]>";
});
})
.def("to_dict", [](const Transform& t) { return transform_to_dict(t); })
.def_static("from_dict", [](const py::dict& d) { return transform_from_dict(d); });
py::class_<Part>(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);
.def_readwrite("grounded", &Part::grounded)
.def("to_dict", [](const Part& p) { return part_to_dict(p); })
.def_static("from_dict", [](const py::dict& d) { return part_from_dict(d); });
auto constraint_class = py::class_<Constraint>(m, "Constraint");
@@ -231,7 +686,9 @@ PYBIND11_MODULE(kcsolve, m)
.def(py::init<>())
.def_readwrite("kind", &Constraint::Limit::kind)
.def_readwrite("value", &Constraint::Limit::value)
.def_readwrite("tolerance", &Constraint::Limit::tolerance);
.def_readwrite("tolerance", &Constraint::Limit::tolerance)
.def("to_dict", [](const Constraint::Limit& l) { return limit_to_dict(l); })
.def_static("from_dict", [](const py::dict& d) { return limit_from_dict(d); });
constraint_class
.def(py::init<>())
@@ -243,7 +700,9 @@ PYBIND11_MODULE(kcsolve, m)
.def_readwrite("type", &Constraint::type)
.def_readwrite("params", &Constraint::params)
.def_readwrite("limits", &Constraint::limits)
.def_readwrite("activated", &Constraint::activated);
.def_readwrite("activated", &Constraint::activated)
.def("to_dict", [](const Constraint& c) { return constraint_to_dict(c); })
.def_static("from_dict", [](const py::dict& d) { return constraint_from_dict(d); });
py::class_<MotionDef>(m, "MotionDef")
.def(py::init<>())
@@ -252,7 +711,9 @@ PYBIND11_MODULE(kcsolve, m)
.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);
.def_readwrite("translation_expr", &MotionDef::translation_expr)
.def("to_dict", [](const MotionDef& m) { return motion_to_dict(m); })
.def_static("from_dict", [](const py::dict& d) { return motion_from_dict(d); });
py::class_<SimulationParams>(m, "SimulationParams")
.def(py::init<>())
@@ -261,7 +722,9 @@ PYBIND11_MODULE(kcsolve, m)
.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);
.def_readwrite("error_tol", &SimulationParams::error_tol)
.def("to_dict", [](const SimulationParams& s) { return sim_to_dict(s); })
.def_static("from_dict", [](const py::dict& d) { return sim_from_dict(d); });
py::class_<SolveContext>(m, "SolveContext")
.def(py::init<>())
@@ -269,20 +732,26 @@ PYBIND11_MODULE(kcsolve, m)
.def_readwrite("constraints", &SolveContext::constraints)
.def_readwrite("motions", &SolveContext::motions)
.def_readwrite("simulation", &SolveContext::simulation)
.def_readwrite("bundle_fixed", &SolveContext::bundle_fixed);
.def_readwrite("bundle_fixed", &SolveContext::bundle_fixed)
.def("to_dict", [](const SolveContext& ctx) { return solve_context_to_dict(ctx); })
.def_static("from_dict", [](const py::dict& d) { return solve_context_from_dict(d); });
py::class_<ConstraintDiagnostic>(m, "ConstraintDiagnostic")
.def(py::init<>())
.def_readwrite("constraint_id", &ConstraintDiagnostic::constraint_id)
.def_readwrite("kind", &ConstraintDiagnostic::kind)
.def_readwrite("detail", &ConstraintDiagnostic::detail);
.def_readwrite("detail", &ConstraintDiagnostic::detail)
.def("to_dict", [](const ConstraintDiagnostic& d) { return diagnostic_to_dict(d); })
.def_static("from_dict", [](const py::dict& d) { return diagnostic_from_dict(d); });
auto result_class = py::class_<SolveResult>(m, "SolveResult");
py::class_<SolveResult::PartResult>(result_class, "PartResult")
.def(py::init<>())
.def_readwrite("id", &SolveResult::PartResult::id)
.def_readwrite("placement", &SolveResult::PartResult::placement);
.def_readwrite("placement", &SolveResult::PartResult::placement)
.def("to_dict", [](const SolveResult::PartResult& pr) { return part_result_to_dict(pr); })
.def_static("from_dict", [](const py::dict& d) { return part_result_from_dict(d); });
result_class
.def(py::init<>())
@@ -290,7 +759,9 @@ PYBIND11_MODULE(kcsolve, m)
.def_readwrite("placements", &SolveResult::placements)
.def_readwrite("dof", &SolveResult::dof)
.def_readwrite("diagnostics", &SolveResult::diagnostics)
.def_readwrite("num_frames", &SolveResult::num_frames);
.def_readwrite("num_frames", &SolveResult::num_frames)
.def("to_dict", [](const SolveResult& r) { return solve_result_to_dict(r); })
.def_static("from_dict", [](const py::dict& d) { return solve_result_from_dict(d); });
// ── IKCSolver (with trampoline for Python subclassing) ─────────

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),
),
]