Paste-into-console test script exercising the full pipeline: - Solver registry and loading - Preference switching between kindred/ondsel - Fixed joint placement matching - Revolute joint DOF reporting - No-ground error code - Solve determinism/stability - Standalone kcsolve API (no FreeCAD Assembly objects) - Diagnose API for overconstrained detection
356 lines
10 KiB
Python
356 lines
10 KiB
Python
"""
|
|
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()
|