Files
solver/tests/test_solver.py
forbes-0023 98051ba0c9 feat: add Phase 1 constraint solver addon, move prior content to GNN/
- Move existing OndselSolver, GNN ML layer, and tooling into GNN/
  directory for integration in later phases
- Add Create addon scaffold: package.xml, Init.py
- Add expression DAG with eval, symbolic diff, simplification
- Add parameter table with fixed/free variable tracking
- Add quaternion rotation as polynomial Expr trees
- Add RigidBody entity (7 DOF: position + unit quaternion)
- Add constraint classes: Coincident, DistancePointPoint, Fixed
- Add Newton-Raphson solver with symbolic Jacobian + numpy lstsq
- Add pre-solve passes: substitution + single-equation
- Add DOF counting via Jacobian SVD rank
- Add KindredSolver IKCSolver bridge for kcsolve integration
- Add 82 unit tests covering all modules

Registers as 'kindred' solver via kcsolve.register_solver() when
loaded by Create's addon_loader.
2026-02-20 20:35:47 -06:00

184 lines
6.3 KiB
Python

"""End-to-end tests for KindredSolver using internal API.
These tests exercise the solver bridge without requiring kcsolve pybind11
bindings (which need a FreeCAD build). Instead we test the internal
pipeline directly.
"""
import math
import pytest
from kindred_solver.constraints import (
CoincidentConstraint,
DistancePointPointConstraint,
FixedConstraint,
)
from kindred_solver.dof import count_dof
from kindred_solver.entities import RigidBody
from kindred_solver.newton import newton_solve
from kindred_solver.params import ParamTable
from kindred_solver.prepass import single_equation_pass, substitution_pass
def _solve_system(bodies, constraint_objs):
"""Helper: run the full solve pipeline on a list of bodies + constraints."""
params = bodies[0].tx # hack to get the shared ParamTable
# Actually, we need the param table. Let's require it as arg.
raise NotImplementedError
class TestCoincidentSolve:
def test_two_bodies_coincident(self):
"""Ground body A at origin, body B at (10,0,0).
Coincident constraint → B moves to origin."""
pt = ParamTable()
body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
body_b = RigidBody("b", pt, (10, 0, 0), (1, 0, 0, 0))
c = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))
residuals = list(c.residuals())
residuals.append(body_b.quat_norm_residual())
quat_groups = [body_b.quat_param_names()]
residuals = substitution_pass(residuals, pt)
residuals = single_equation_pass(residuals, pt)
converged = newton_solve(residuals, pt, quat_groups=quat_groups)
assert converged
env = pt.get_env()
pos = body_b.extract_position(env)
assert abs(pos[0]) < 1e-8
assert abs(pos[1]) < 1e-8
assert abs(pos[2]) < 1e-8
def test_coincident_with_markers(self):
"""Body A at origin, body B at (10,0,0).
Marker on A at local (5,0,0), marker on B at local (-5,0,0).
After solve, B should be at (10,0,0) since 5+0 == 10-5."""
pt = ParamTable()
body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
body_b = RigidBody("b", pt, (20, 0, 0), (1, 0, 0, 0))
c = CoincidentConstraint(body_a, (5, 0, 0), body_b, (-5, 0, 0))
residuals = list(c.residuals())
residuals.append(body_b.quat_norm_residual())
quat_groups = [body_b.quat_param_names()]
residuals = substitution_pass(residuals, pt)
converged = newton_solve(residuals, pt, quat_groups=quat_groups)
assert converged
env = pt.get_env()
pos = body_b.extract_position(env)
assert abs(pos[0] - 10.0) < 1e-8
class TestDistanceSolve:
def test_distance_from_origin(self):
"""Ground A at origin, B starts at (1,0,0).
Distance constraint d=5 → B moves to (5,0,0)."""
pt = ParamTable()
body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
body_b = RigidBody("b", pt, (1, 0, 0), (1, 0, 0, 0))
c = DistancePointPointConstraint(
body_a,
(0, 0, 0),
body_b,
(0, 0, 0),
5.0,
)
residuals = list(c.residuals())
residuals.append(body_b.quat_norm_residual())
quat_groups = [body_b.quat_param_names()]
residuals = substitution_pass(residuals, pt)
converged = newton_solve(residuals, pt, quat_groups=quat_groups)
assert converged
env = pt.get_env()
pos = body_b.extract_position(env)
dist = math.sqrt(pos[0] ** 2 + pos[1] ** 2 + pos[2] ** 2)
assert abs(dist - 5.0) < 1e-8
class TestFixedSolve:
def test_fixed_weld(self):
"""Ground A at origin, B at (5,0,0) with identity rotation.
Fixed constraint with identity markers → B stays at same pose as A."""
pt = ParamTable()
body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
body_b = RigidBody("b", pt, (5, 0, 0), (1, 0, 0, 0))
c = FixedConstraint(
body_a,
(0, 0, 0),
(1, 0, 0, 0),
body_b,
(0, 0, 0),
(1, 0, 0, 0),
)
residuals = list(c.residuals())
residuals.append(body_b.quat_norm_residual())
quat_groups = [body_b.quat_param_names()]
residuals = substitution_pass(residuals, pt)
converged = newton_solve(residuals, pt, quat_groups=quat_groups)
assert converged
env = pt.get_env()
pos = body_b.extract_position(env)
assert abs(pos[0]) < 1e-8
assert abs(pos[1]) < 1e-8
assert abs(pos[2]) < 1e-8
quat = body_b.extract_quaternion(env)
assert abs(quat[0] - 1.0) < 1e-8 # w
assert abs(quat[1]) < 1e-8 # x
assert abs(quat[2]) < 1e-8 # y
assert abs(quat[3]) < 1e-8 # z
class TestDOFCounting:
def test_single_body_unconstrained(self):
"""One non-grounded body: 7 params - 1 quat norm = 6 DOF."""
pt = ParamTable()
body = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0))
residuals = [body.quat_norm_residual()]
dof = count_dof(residuals, pt)
assert dof == 6
def test_two_bodies_coincident(self):
"""Grounded A + free B with coincident: 7 - (3 coincident + 1 quat) = 3 DOF."""
pt = ParamTable()
body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
body_b = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
c = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))
residuals = list(c.residuals()) + [body_b.quat_norm_residual()]
residuals = substitution_pass(residuals, pt)
dof = count_dof(residuals, pt)
assert dof == 3 # 3 rotation DOF remain
def test_two_bodies_fixed(self):
"""Grounded A + free B with fixed: 7 - (6 fixed + 1 quat) = 0 DOF."""
pt = ParamTable()
body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
body_b = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
c = FixedConstraint(
body_a,
(0, 0, 0),
(1, 0, 0, 0),
body_b,
(0, 0, 0),
(1, 0, 0, 0),
)
residuals = list(c.residuals()) + [body_b.quat_norm_residual()]
residuals = substitution_pass(residuals, pt)
dof = count_dof(residuals, pt)
assert dof == 0