# 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 * # . * # * # ***************************************************************************/ """ Solver integration tests for Phase 1e (KCSolve refactor). These tests verify that the AssemblyObject → IKCSolver → OndselAdapter pipeline produces correct results via the full FreeCAD stack. They complement the C++ unit tests in tests/src/Mod/Assembly/Solver/. """ import os import tempfile import unittest import FreeCAD as App import JointObject class TestSolverIntegration(unittest.TestCase): """Full-stack solver regression tests exercising AssemblyObject.solve().""" def setUp(self): 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") self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints") def tearDown(self): App.closeDocument(self.doc.Name) # ── Helpers ───────────────────────────────────────────────────── def _make_box(self, x=0, y=0, z=0, size=10): """Create a Part::Box inside the assembly with a given offset.""" 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): """Create a grounded joint for the given object.""" gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint") JointObject.GroundedJoint(gnd, obj) return gnd def _make_joint(self, joint_type, ref1, ref2): """Create a joint of the given type connecting two (obj, subelements) pairs. joint_type: integer JointType enum value (0=Fixed, 1=Revolute, etc.) ref1, ref2: tuples of (obj, [sub_element, sub_element]) """ 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) # Fixed joint (type 0) connecting Face6+Vertex7 on each box. self._make_joint( 0, [box2, ["Face6", "Vertex7"]], [box1, ["Face6", "Vertex7"]], ) # After setJointConnectors, solve() was already called internally. # Verify that box1 moved to match box2. 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 (return 0).""" box1 = self._make_box(0, 0, 0) box2 = self._make_box(100, 0, 0) self._ground(box1) # Revolute joint (type 1) connecting Face6+Vertex7. 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) # Fixed joint but no ground. 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_returns_redundancy(self): """Over-constrained assembly → solve returns -2 (redundant).""" box1 = self._make_box(0, 0, 0) box2 = self._make_box(50, 0, 0) self._ground(box1) # Two fixed joints between the same faces → redundant. self._make_joint( 0, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]], ) self._make_joint( 0, [box1, ["Face5", "Vertex5"]], [box2, ["Face5", "Vertex5"]], ) result = self.assembly.solve() self.assertEqual(result, -2, "Redundant constraints should return -2") def test_export_asmt(self): """exportAsASMT() produces a non-empty file.""" box1 = self._make_box(0, 0, 0) box2 = self._make_box(50, 0, 0) self._ground(box1) self._make_joint( 0, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]], ) self.assembly.solve() with tempfile.NamedTemporaryFile(suffix=".asmt", delete=False) as f: path = f.name try: self.assembly.exportAsASMT(path) self.assertTrue(os.path.exists(path), "ASMT file should exist") self.assertGreater( os.path.getsize(path), 0, "ASMT file should be non-empty" ) finally: if os.path.exists(path): os.unlink(path) def test_solve_multiple_times_stable(self): """Solving the same assembly 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", )