# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * * # * Copyright (c) 2025 Kindred Systems * # * * # * 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 * # * . * # * * # *************************************************************************** """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)