Files
Qinventor/tests/assembly_suite.py
T

212 lines
11 KiB
Python

"""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"]))