All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
Phase 1e: Add C++ gtest and Python unittest coverage for the pluggable solver system introduced in Phases 1a-1d. C++ tests (KCSolve_tests_run): - SolverRegistryTest (8 tests): register/get, duplicates, defaults, available list, joints_for capability queries - OndselAdapterTest (10 tests): identity checks, supported/unsupported joints, Fixed/Revolute solve round-trips, no-grounded-parts handling, exception safety, drag protocol (pre_drag/drag_step/post_drag), redundant constraint diagnostics Python tests (TestSolverIntegration): - Full-stack solve via AssemblyObject → IKCSolver → OndselAdapter - Fixed joint placement matching, revolute joint success - No-ground error code (-6), redundancy detection (-2) - ASMT export produces non-empty file - Deterministic solve stability (solve twice → same result)
217 lines
8.1 KiB
Python
217 lines
8.1 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/>. *
|
|
# *
|
|
# ***************************************************************************/
|
|
|
|
"""
|
|
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",
|
|
)
|