"""Assembly services checked against the real FreeCAD solver and transactions.""" import json import sys import traceback from pathlib import Path import FreeCAD as App SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor" if str(SOURCE) not in sys.path: sys.path.insert(0, str(SOURCE)) import qi_model as model def _placement(value): return tuple(float(v) for v in (value.Base.x, value.Base.y, value.Base.z, *value.Rotation.Q)) def _state(doc, assembly): components = [obj for obj in assembly.Group if obj.isDerivedFrom("App::Link")] return { "objects": tuple(obj.Name for obj in doc.Objects), "members": tuple(obj.Name for obj in assembly.Group), "joints": tuple(obj.Name for group in assembly.Group if group.isDerivedFrom("Assembly::JointGroup") for obj in group.Group), "placements": {obj.Name: _placement(obj.LinkPlacement) for obj in components}, "undo": doc.UndoCount, "redo": doc.RedoCount, } def _same_state(before, after): for key in ("objects", "members", "joints", "undo", "redo"): assert before[key] == after[key], "Rejected joint changed %s" % key assert before["placements"].keys() == after["placements"].keys() for name, expected in before["placements"].items(): assert all(abs(a - b) <= 1e-8 for a, b in zip(expected, after["placements"][name])), \ "Rejected joint moved component %s" % name def _expect_rejected(doc, assembly, operation): before = _state(doc, assembly) try: operation() except ValueError as exc: error = str(exc) assert error, "Joint rejection needs an explanation" else: raise AssertionError("Invalid joint was accepted") _same_state(before, _state(doc, assembly)) return error def run(output_dir): output = Path(output_dir) output.mkdir(parents=True, exist_ok=True) checks, documents = [], [] def check(name, function): try: evidence = function() checks.append({"name": name, "passed": True, "evidence": evidence}) except Exception: checks.append({"name": name, "passed": False, "error": traceback.format_exc()}) def fixture(name): doc = model.new_assembly(name) documents.append(doc.Name) assembly = doc.getObject("Assembly") library = doc.addObject("App::Part", "Definitions") source = model.create_box(10, 8, 6, doc) library.addObject(source) model._hide(source) model._hide(library) components = [model.add_component( assembly, source, App.Placement(App.Vector(25 * index, 0, 0), App.Rotation())) for index in range(3)] model.ground_component(assembly, components[0]) doc.recompute() assert assembly.isPartGrounded(components[0]), "Fixture base is not grounded" return doc, assembly, components, source def self_connection(): doc, assembly, components, _ = fixture("Соединение с собой") message = _expect_rejected(doc, assembly, lambda: model.create_joint(assembly, components[0], components[0])) return {"message": message, "unchanged": True} def outside_component(): doc, assembly, components, source = fixture("Посторонний компонент") outside = doc.addObject("App::Link", "Outside") outside.setLink(source) doc.recompute() joint_error = _expect_rejected(doc, assembly, lambda: model.create_joint(assembly, components[0], outside)) ground_error = _expect_rejected(doc, assembly, lambda: model.ground_component(assembly, outside)) return {"joint_message": joint_error, "ground_message": ground_error, "unchanged": True} def foreign_component(): doc, assembly, components, _ = fixture("Другой документ") other, _, foreign, _ = fixture("Посторонняя сборка") foreign_before = _state(other, other.getObject("Assembly")) joint_error = _expect_rejected(doc, assembly, lambda: model.create_joint(assembly, components[0], foreign[1])) ground_error = _expect_rejected(doc, assembly, lambda: model.ground_component(assembly, foreign[1])) _same_state(foreign_before, _state(other, other.getObject("Assembly"))) return {"joint_message": joint_error, "ground_message": ground_error, "both_documents_unchanged": True} def conflicting_joint(): doc, assembly, components, _ = fixture("Конфликт соединений") offset = App.Placement(App.Vector(25, 0, 0), App.Rotation()) model.create_joint(assembly, components[0], components[1], "Fixed", offset) model.create_joint(assembly, components[1], components[2], "Fixed", offset) doc.recompute() assert assembly.solve(False) == 0, "Fixture constraints do not solve" assert abs(components[1].LinkPlacement.Base.x - 25) < 1e-7 assert abs(components[2].LinkPlacement.Base.x - 50) < 1e-7 incompatible = App.Placement(App.Vector(30, 0, 0), App.Rotation()) message = _expect_rejected(doc, assembly, lambda: model.create_joint(assembly, components[0], components[1], "Fixed", incompatible)) assert assembly.solve(False) == 0, "Rollback left conflicting constraints" return {"message": message, "all_three_placements_restored": True, "remaining_solver_status": 0} def conflicting_loop(): doc, assembly, components, _ = fixture("Конфликт замкнутой цепи") offset = App.Placement(App.Vector(25, 0, 0), App.Rotation()) first_joint = model.create_joint(assembly, components[0], components[1], "Fixed", offset) second_joint = model.create_joint(assembly, components[1], components[2], "Fixed", offset) doc.recompute() assert assembly.solve(False) == 0, "Fixture chain does not solve" assert abs(components[1].LinkPlacement.Base.x - 25) < 1e-7 assert abs(components[2].LinkPlacement.Base.x - 50) < 1e-7 incompatible = App.Placement(App.Vector(55, 0, 0), App.Rotation()) message = _expect_rejected(doc, assembly, lambda: model.create_joint(assembly, components[0], components[2], "Fixed", incompatible)) assert doc.getObject(first_joint.Name) is first_joint assert doc.getObject(second_joint.Name) is second_joint assert assembly.solve(False) == 0, "Loop rejection broke an earlier joint" assert abs(components[1].LinkPlacement.Base.x - 25) < 1e-7 assert abs(components[2].LinkPlacement.Base.x - 50) < 1e-7 return {"message": message, "indirect_loop_rejected": True, "both_previous_joints_preserved": True, "all_three_placements_restored": True} def accepted_joint_undo_redo_reopen(): doc, assembly, components, _ = fixture("Сохранение соединения") before = _state(doc, assembly) joint = model.create_joint(assembly, components[0], components[1], "Fixed", App.Placement(App.Vector(32, 4, 2), App.Rotation())) joint_name = joint.Name accepted = _state(doc, assembly) assert accepted["undo"] == before["undo"] + 1, "Joint is not one undo step" assert assembly.solve(False) == 0 expected = App.Placement(App.Vector(32, 4, 2), App.Rotation()) assert components[1].LinkPlacement.isSame(expected, 1e-7), "Accepted joint did not move the component" doc.undo() doc.recompute() assert doc.getObject(joint_name) is None, "Undo kept the new joint" undone = _state(doc, assembly) assert undone["objects"] == before["objects"] and undone["joints"] == before["joints"] for name in before["placements"]: assert all(abs(a - b) < 1e-8 for a, b in zip(before["placements"][name], undone["placements"][name])), \ "Undo did not restore component %s" % name doc.redo() doc.recompute() assert doc.getObject(joint_name) is not None, "Redo did not restore the joint" assert components[1].LinkPlacement.isSame(expected, 1e-7), "Redo lost solved placement" component_name = components[1].Name path = model.save_project(doc, output / "atomic-joint.FCStd") App.closeDocument(doc.Name) reopened = App.openDocument(path) documents.append(reopened.Name) reopened_assembly = reopened.getObject("Assembly") assert reopened.getObject(joint_name) is not None, "Reopen lost the joint" assert reopened_assembly.solve(False) == 0, "Saved assembly does not solve" assert reopened.getObject(component_name).LinkPlacement.isSame(expected, 1e-7), \ "Reopen lost solved placement" return {"native_joint": True, "undo_redo": True, "reopened": True, "project": path, "solver_status": 0} for name, function in [ ("Joint rejects connection to the same component before mutation", self_connection), ("Joint and grounding reject components outside the assembly", outside_component), ("Joint and grounding reject components from another document", foreign_component), ("Conflicting native joint rolls back objects and all component placements", conflicting_joint), ("Conflicting indirect loop preserves both previous fixed joints", conflicting_loop), ("Accepted native joint is one undo step and survives redo save reopen", accepted_joint_undo_redo_reopen), ]: check(name, function) for name in set(documents): if name in App.listDocuments(): App.closeDocument(name) result = {"passed": all(row["passed"] for row in checks), "checks": checks, "summary": {"total": len(checks), "passed": sum(row["passed"] for row in checks), "failed": sum(not row["passed"] for row in checks)}, "gui_exercised": bool(App.GuiUp)} (output / "assembly-suite.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") return result if __name__ == "__main__": result = run(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] / "artifacts" / "assembly") print(json.dumps(result["summary"]))