diff --git a/tests/console_test_phase5.py b/tests/console_test_phase5.py new file mode 100644 index 0000000..0255fa7 --- /dev/null +++ b/tests/console_test_phase5.py @@ -0,0 +1,355 @@ +""" +Phase 5 in-client console tests. + +Paste into the FreeCAD Python console (or run via: exec(open(...).read())). +Tests the full Assembly -> KindredSolver pipeline without the unittest harness. + +Expected output: all lines print PASS. Any FAIL indicates a regression. +""" + +import FreeCAD as App +import JointObject +import kcsolve + +_pref = App.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly") +_orig_solver = _pref.GetString("Solver", "") + +_results = [] + + +def _report(name, passed, detail=""): + status = "PASS" if passed else "FAIL" + msg = f" [{status}] {name}" + if detail: + msg += f" — {detail}" + print(msg) + _results.append((name, passed)) + + +def _new_doc(name="Phase5Test"): + if App.ActiveDocument and App.ActiveDocument.Name == name: + App.closeDocument(name) + App.newDocument(name) + App.setActiveDocument(name) + return App.ActiveDocument + + +def _cleanup(doc): + App.closeDocument(doc.Name) + + +def _make_assembly(doc): + asm = doc.addObject("Assembly::AssemblyObject", "Assembly") + asm.resetSolver() + jg = asm.newObject("Assembly::JointGroup", "Joints") + return asm, jg + + +def _make_box(asm, x=0, y=0, z=0, size=10): + box = asm.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(jg, obj): + gnd = jg.newObject("App::FeaturePython", "GroundedJoint") + JointObject.GroundedJoint(gnd, obj) + return gnd + + +def _make_joint(jg, joint_type, ref1, ref2): + joint = jg.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 + + +# ── Test 1: Registry ──────────────────────────────────────────────── + + +def test_solver_registry(): + """Verify kindred solver is registered and available.""" + names = kcsolve.available() + _report( + "registry: kindred in available()", "kindred" in names, f"available={names}" + ) + + solver = kcsolve.load("kindred") + _report("registry: load('kindred') succeeds", solver is not None) + _report( + "registry: solver name", + solver.name() == "Kindred (Newton-Raphson)", + f"got '{solver.name()}'", + ) + + joints = solver.supported_joints() + _report( + "registry: supported_joints non-empty", + len(joints) > 0, + f"{len(joints)} joint types", + ) + + +# ── Test 2: Preference switching ──────────────────────────────────── + + +def test_preference_switching(): + """Verify solver preference controls which backend is used.""" + doc = _new_doc("PrefTest") + try: + # Set to kindred + _pref.SetString("Solver", "kindred") + asm, jg = _make_assembly(doc) + + box1 = _make_box(asm, 0, 0, 0) + box2 = _make_box(asm, 50, 0, 0) + _ground(box1) + _make_joint(jg, 0, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]]) + + result = asm.solve() + _report( + "pref: kindred solve succeeds", result == 0, f"solve() returned {result}" + ) + + # Switch back to ondsel + _pref.SetString("Solver", "ondsel") + asm.resetSolver() + result2 = asm.solve() + _report( + "pref: ondsel solve succeeds after switch", + result2 == 0, + f"solve() returned {result2}", + ) + finally: + _cleanup(doc) + + +# ── Test 3: Fixed joint ───────────────────────────────────────────── + + +def test_fixed_joint(): + """Two boxes + ground + fixed joint -> placements match.""" + _pref.SetString("Solver", "kindred") + doc = _new_doc("FixedTest") + try: + asm, jg = _make_assembly(doc) + box1 = _make_box(asm, 10, 20, 30) + box2 = _make_box(asm, 40, 50, 60) + _ground(box2) + _make_joint(jg, 0, [box2, ["Face6", "Vertex7"]], [box1, ["Face6", "Vertex7"]]) + + same = box1.Placement.isSame(box2.Placement, 1e-6) + _report( + "fixed: box1 matches box2 placement", + same, + f"box1={box1.Placement.Base}, box2={box2.Placement.Base}", + ) + finally: + _cleanup(doc) + + +# ── Test 4: Revolute joint + DOF ───────────────────────────────────── + + +def test_revolute_dof(): + """Revolute joint -> solve succeeds, DOF = 1.""" + _pref.SetString("Solver", "kindred") + doc = _new_doc("RevoluteTest") + try: + asm, jg = _make_assembly(doc) + box1 = _make_box(asm, 0, 0, 0) + box2 = _make_box(asm, 100, 0, 0) + _ground(box1) + _make_joint(jg, 1, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]]) + + result = asm.solve() + _report("revolute: solve succeeds", result == 0, f"solve() returned {result}") + + dof = asm.getLastDoF() + _report("revolute: DOF = 1", dof == 1, f"DOF = {dof}") + finally: + _cleanup(doc) + + +# ── Test 5: No ground ─────────────────────────────────────────────── + + +def test_no_ground(): + """No grounded parts -> returns -6.""" + _pref.SetString("Solver", "kindred") + doc = _new_doc("NoGroundTest") + try: + asm, jg = _make_assembly(doc) + box1 = _make_box(asm, 0, 0, 0) + box2 = _make_box(asm, 50, 0, 0) + + joint = jg.newObject("App::FeaturePython", "Joint") + JointObject.Joint(joint, 0) + refs = [[box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]]] + joint.Proxy.setJointConnectors(joint, refs) + + result = asm.solve() + _report("no-ground: returns -6", result == -6, f"solve() returned {result}") + finally: + _cleanup(doc) + + +# ── Test 6: Solve stability ───────────────────────────────────────── + + +def test_stability(): + """Solving twice gives identical placements.""" + _pref.SetString("Solver", "kindred") + doc = _new_doc("StabilityTest") + try: + asm, jg = _make_assembly(doc) + box1 = _make_box(asm, 10, 20, 30) + box2 = _make_box(asm, 40, 50, 60) + _ground(box2) + _make_joint(jg, 0, [box2, ["Face6", "Vertex7"]], [box1, ["Face6", "Vertex7"]]) + + asm.solve() + plc1 = App.Placement(box1.Placement) + asm.solve() + plc2 = box1.Placement + + same = plc1.isSame(plc2, 1e-6) + _report("stability: two solves identical", same) + finally: + _cleanup(doc) + + +# ── Test 7: Standalone solver API ──────────────────────────────────── + + +def test_standalone_api(): + """Use kcsolve types directly without FreeCAD Assembly objects.""" + solver = kcsolve.load("kindred") + + # Two parts: one grounded, one free + p1 = kcsolve.Part() + p1.id = "base" + p1.placement = kcsolve.Transform.identity() + p1.grounded = True + + p2 = kcsolve.Part() + p2.id = "arm" + p2.placement = kcsolve.Transform() + p2.placement.position = [100.0, 0.0, 0.0] + p2.placement.quaternion = [1.0, 0.0, 0.0, 0.0] + p2.grounded = False + + # Fixed joint + c = kcsolve.Constraint() + c.id = "fix1" + c.part_i = "base" + c.marker_i = kcsolve.Transform.identity() + c.part_j = "arm" + c.marker_j = kcsolve.Transform.identity() + c.type = kcsolve.BaseJointKind.Fixed + + ctx = kcsolve.SolveContext() + ctx.parts = [p1, p2] + ctx.constraints = [c] + + result = solver.solve(ctx) + _report( + "standalone: solve status", + result.status == kcsolve.SolveStatus.Success, + f"status={result.status}", + ) + _report("standalone: DOF = 0", result.dof == 0, f"dof={result.dof}") + + # Check that arm moved to origin + for pr in result.placements: + if pr.id == "arm": + dist = sum(x**2 for x in pr.placement.position) ** 0.5 + _report("standalone: arm at origin", dist < 1e-6, f"distance={dist:.2e}") + break + + +# ── Test 8: Diagnose API ──────────────────────────────────────────── + + +def test_diagnose(): + """Diagnose overconstrained system via standalone API.""" + solver = kcsolve.load("kindred") + + p1 = kcsolve.Part() + p1.id = "base" + p1.placement = kcsolve.Transform.identity() + p1.grounded = True + + p2 = kcsolve.Part() + p2.id = "arm" + p2.placement = kcsolve.Transform() + p2.placement.position = [50.0, 0.0, 0.0] + p2.placement.quaternion = [1.0, 0.0, 0.0, 0.0] + p2.grounded = False + + # Two fixed joints = overconstrained + c1 = kcsolve.Constraint() + c1.id = "fix1" + c1.part_i = "base" + c1.marker_i = kcsolve.Transform.identity() + c1.part_j = "arm" + c1.marker_j = kcsolve.Transform.identity() + c1.type = kcsolve.BaseJointKind.Fixed + + c2 = kcsolve.Constraint() + c2.id = "fix2" + c2.part_i = "base" + c2.marker_i = kcsolve.Transform.identity() + c2.part_j = "arm" + c2.marker_j = kcsolve.Transform.identity() + c2.type = kcsolve.BaseJointKind.Fixed + + ctx = kcsolve.SolveContext() + ctx.parts = [p1, p2] + ctx.constraints = [c1, c2] + + diags = solver.diagnose(ctx) + _report( + "diagnose: returns diagnostics", len(diags) > 0, f"{len(diags)} diagnostic(s)" + ) + if diags: + kinds = [d.kind for d in diags] + _report( + "diagnose: found redundant", + kcsolve.DiagnosticKind.Redundant in kinds, + f"kinds={[str(k) for k in kinds]}", + ) + + +# ── Run all ────────────────────────────────────────────────────────── + + +def run_all(): + print("\n=== Phase 5 Console Tests ===\n") + + test_solver_registry() + test_preference_switching() + test_fixed_joint() + test_revolute_dof() + test_no_ground() + test_stability() + test_standalone_api() + test_diagnose() + + # Restore original preference + _pref.SetString("Solver", _orig_solver) + + # Summary + passed = sum(1 for _, p in _results if p) + total = len(_results) + print(f"\n=== {passed}/{total} passed ===\n") + if passed < total: + failed = [name for name, p in _results if not p] + print(f"FAILED: {', '.join(failed)}") + + +run_all()