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).
521 lines
17 KiB
Python
521 lines
17 KiB
Python
# 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/>. *
|
|
# * *
|
|
# ***************************************************************************
|
|
|
|
"""Unit tests for the kcsolve pybind11 module."""
|
|
|
|
import unittest
|
|
|
|
|
|
class TestKCSolveImport(unittest.TestCase):
|
|
"""Verify that the kcsolve module loads and exposes expected symbols."""
|
|
|
|
def test_import(self):
|
|
import kcsolve
|
|
|
|
for sym in (
|
|
"IKCSolver",
|
|
"OndselAdapter",
|
|
"Transform",
|
|
"Part",
|
|
"Constraint",
|
|
"SolveContext",
|
|
"SolveResult",
|
|
"BaseJointKind",
|
|
"SolveStatus",
|
|
"available",
|
|
"load",
|
|
"register_solver",
|
|
):
|
|
self.assertTrue(hasattr(kcsolve, sym), f"missing symbol: {sym}")
|
|
|
|
def test_api_version(self):
|
|
import kcsolve
|
|
|
|
self.assertEqual(kcsolve.API_VERSION_MAJOR, 1)
|
|
|
|
|
|
class TestKCSolveTypes(unittest.TestCase):
|
|
"""Verify struct/enum bindings behave correctly."""
|
|
|
|
def test_transform_identity(self):
|
|
import kcsolve
|
|
|
|
t = kcsolve.Transform.identity()
|
|
self.assertEqual(list(t.position), [0.0, 0.0, 0.0])
|
|
self.assertEqual(list(t.quaternion), [1.0, 0.0, 0.0, 0.0]) # w,x,y,z
|
|
|
|
def test_part_defaults(self):
|
|
import kcsolve
|
|
|
|
p = kcsolve.Part()
|
|
self.assertEqual(p.id, "")
|
|
self.assertAlmostEqual(p.mass, 1.0)
|
|
self.assertFalse(p.grounded)
|
|
|
|
def test_solve_context_construction(self):
|
|
import kcsolve
|
|
|
|
ctx = kcsolve.SolveContext()
|
|
self.assertEqual(len(ctx.parts), 0)
|
|
self.assertEqual(len(ctx.constraints), 0)
|
|
|
|
p = kcsolve.Part()
|
|
p.id = "part1"
|
|
# pybind11 def_readwrite on std::vector returns a copy,
|
|
# so we must assign the whole list back.
|
|
ctx.parts = [p]
|
|
self.assertEqual(len(ctx.parts), 1)
|
|
self.assertEqual(ctx.parts[0].id, "part1")
|
|
|
|
def test_enum_values(self):
|
|
import kcsolve
|
|
|
|
self.assertEqual(int(kcsolve.SolveStatus.Success), 0)
|
|
# BaseJointKind.Fixed should exist
|
|
self.assertIsNotNone(kcsolve.BaseJointKind.Fixed)
|
|
# DiagnosticKind should exist
|
|
self.assertIsNotNone(kcsolve.DiagnosticKind.Redundant)
|
|
|
|
def test_constraint_fields(self):
|
|
import kcsolve
|
|
|
|
c = kcsolve.Constraint()
|
|
c.id = "Joint001"
|
|
c.part_i = "part1"
|
|
c.part_j = "part2"
|
|
c.type = kcsolve.BaseJointKind.Fixed
|
|
self.assertEqual(c.id, "Joint001")
|
|
self.assertEqual(c.type, kcsolve.BaseJointKind.Fixed)
|
|
|
|
def test_solve_result_fields(self):
|
|
import kcsolve
|
|
|
|
r = kcsolve.SolveResult()
|
|
self.assertEqual(r.status, kcsolve.SolveStatus.Success)
|
|
self.assertEqual(r.dof, -1)
|
|
self.assertEqual(len(r.placements), 0)
|
|
|
|
|
|
class TestKCSolveRegistry(unittest.TestCase):
|
|
"""Verify SolverRegistry wrapper functions."""
|
|
|
|
def test_available_returns_list(self):
|
|
import kcsolve
|
|
|
|
result = kcsolve.available()
|
|
self.assertIsInstance(result, list)
|
|
|
|
def test_load_ondsel(self):
|
|
import kcsolve
|
|
|
|
solver = kcsolve.load("ondsel")
|
|
# Ondsel should be registered by FreeCAD init
|
|
if solver is not None:
|
|
self.assertIn("Ondsel", solver.name())
|
|
|
|
def test_load_unknown_returns_none(self):
|
|
import kcsolve
|
|
|
|
solver = kcsolve.load("nonexistent_solver_xyz")
|
|
self.assertIsNone(solver)
|
|
|
|
def test_get_set_default(self):
|
|
import kcsolve
|
|
|
|
original = kcsolve.get_default()
|
|
# Setting unknown solver should return False
|
|
self.assertFalse(kcsolve.set_default("nonexistent_solver_xyz"))
|
|
# Default should be unchanged
|
|
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."""
|
|
|
|
def _make_solver_class(self):
|
|
import kcsolve
|
|
|
|
class _DummySolver(kcsolve.IKCSolver):
|
|
def name(self):
|
|
return "DummyPySolver"
|
|
|
|
def supported_joints(self):
|
|
return [
|
|
kcsolve.BaseJointKind.Fixed,
|
|
kcsolve.BaseJointKind.Revolute,
|
|
]
|
|
|
|
def solve(self, ctx):
|
|
r = kcsolve.SolveResult()
|
|
r.status = kcsolve.SolveStatus.Success
|
|
parts = ctx.parts # copy from C++ vector
|
|
r.dof = len(parts) * 6
|
|
placements = []
|
|
for p in parts:
|
|
pr = kcsolve.SolveResult.PartResult()
|
|
pr.id = p.id
|
|
pr.placement = p.placement
|
|
placements.append(pr)
|
|
r.placements = placements
|
|
return r
|
|
|
|
return _DummySolver
|
|
|
|
def test_instantiate_python_solver(self):
|
|
cls = self._make_solver_class()
|
|
solver = cls()
|
|
self.assertEqual(solver.name(), "DummyPySolver")
|
|
self.assertEqual(len(solver.supported_joints()), 2)
|
|
|
|
def test_python_solver_solve(self):
|
|
import kcsolve
|
|
|
|
cls = self._make_solver_class()
|
|
solver = cls()
|
|
|
|
ctx = kcsolve.SolveContext()
|
|
p = kcsolve.Part()
|
|
p.id = "box1"
|
|
p.grounded = True
|
|
ctx.parts = [p]
|
|
|
|
result = solver.solve(ctx)
|
|
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
|
|
self.assertEqual(result.dof, 6)
|
|
self.assertEqual(len(result.placements), 1)
|
|
self.assertEqual(result.placements[0].id, "box1")
|
|
|
|
def test_register_and_roundtrip(self):
|
|
import kcsolve
|
|
|
|
cls = self._make_solver_class()
|
|
# Use a unique name to avoid collision across test runs
|
|
name = "test_dummy_roundtrip"
|
|
kcsolve.register_solver(name, cls)
|
|
|
|
self.assertIn(name, kcsolve.available())
|
|
|
|
loaded = kcsolve.load(name)
|
|
self.assertIsNotNone(loaded)
|
|
self.assertEqual(loaded.name(), "DummyPySolver")
|
|
|
|
ctx = kcsolve.SolveContext()
|
|
result = loaded.solve(ctx)
|
|
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
|
|
|
|
def test_default_virtuals(self):
|
|
"""Default implementations of optional virtuals should not crash."""
|
|
import kcsolve
|
|
|
|
cls = self._make_solver_class()
|
|
solver = cls()
|
|
self.assertTrue(solver.is_deterministic())
|
|
self.assertFalse(solver.supports_bundle_fixed())
|
|
|
|
ctx = kcsolve.SolveContext()
|
|
diags = solver.diagnose(ctx)
|
|
self.assertEqual(len(diags), 0)
|