212 lines
11 KiB
Python
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"]))
|