"""Tests for Phase 2 constraint residual generation.""" import math import pytest from kindred_solver.constraints import ( AngleConstraint, BallConstraint, CamConstraint, ConcentricConstraint, CylindricalConstraint, DistanceCylSphConstraint, GearConstraint, LineInPlaneConstraint, ParallelConstraint, PerpendicularConstraint, PlanarConstraint, PointInPlaneConstraint, PointOnLineConstraint, RackPinionConstraint, RevoluteConstraint, ScrewConstraint, SliderConstraint, SlotConstraint, TangentConstraint, UniversalConstraint, ) from kindred_solver.entities import RigidBody from kindred_solver.params import ParamTable ID_QUAT = (1.0, 0.0, 0.0, 0.0) # 90-deg about Y: Z-axis of body rotates to point along X _c = math.cos(math.pi / 4) _s = math.sin(math.pi / 4) ROT_90Y = (_c, 0.0, _s, 0.0) ROT_90Z = (_c, 0.0, 0.0, _s) # ── Point constraints ──────────────────────────────────────────────── class TestPointOnLine: def test_on_line(self): """Point at (0,0,5) is on Z-axis line through origin.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 5), (1, 0, 0, 0)) c = PointOnLineConstraint(b2, (0, 0, 0), ID_QUAT, b1, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_off_line(self): """Point at (3,0,5) is NOT on Z-axis line through origin.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (3, 0, 5), (1, 0, 0, 0)) c = PointOnLineConstraint(b2, (0, 0, 0), ID_QUAT, b1, (0, 0, 0), ID_QUAT) env = pt.get_env() vals = [r.eval(env) for r in c.residuals()] assert any(abs(v) > 0.1 for v in vals) def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = PointOnLineConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 3 class TestPointInPlane: def test_in_plane(self): """Point at (3,4,0) is in XY plane through origin.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (3, 4, 0), (1, 0, 0, 0)) c = PointInPlaneConstraint(b2, (0, 0, 0), ID_QUAT, b1, (0, 0, 0), ID_QUAT) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_above_plane(self): """Point at (0,0,7) is 7 above XY plane.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 7), (1, 0, 0, 0)) c = PointInPlaneConstraint(b2, (0, 0, 0), ID_QUAT, b1, (0, 0, 0), ID_QUAT) env = pt.get_env() assert abs(c.residuals()[0].eval(env) - 7.0) < 1e-10 def test_with_offset(self): """Point at (0,0,5) with offset=5 → residual 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 5), (1, 0, 0, 0)) c = PointInPlaneConstraint( b2, (0, 0, 0), ID_QUAT, b1, (0, 0, 0), ID_QUAT, offset=5.0 ) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = PointInPlaneConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 1 # ── Orientation constraints ────────────────────────────────────────── class TestParallel: def test_parallel_same(self): """Both bodies with identity rotation → Z-axes parallel → residuals 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (5, 0, 0), (1, 0, 0, 0)) c = ParallelConstraint(b1, ID_QUAT, b2, ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_not_parallel(self): """One body rotated 90-deg about Y → Z-axes perpendicular.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (5, 0, 0), ROT_90Y) c = ParallelConstraint(b1, ID_QUAT, b2, ID_QUAT) env = pt.get_env() vals = [r.eval(env) for r in c.residuals()] assert any(abs(v) > 0.1 for v in vals) def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = ParallelConstraint(b1, ID_QUAT, b2, ID_QUAT) assert len(c.residuals()) == 3 class TestPerpendicular: def test_perpendicular(self): """One body rotated 90-deg about Y → Z-axes perpendicular.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), ROT_90Y) c = PerpendicularConstraint(b1, ID_QUAT, b2, ID_QUAT) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_not_perpendicular(self): """Same orientation → not perpendicular.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = PerpendicularConstraint(b1, ID_QUAT, b2, ID_QUAT) env = pt.get_env() # dot(z,z) = 1 ≠ 0 assert abs(c.residuals()[0].eval(env) - 1.0) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = PerpendicularConstraint(b1, ID_QUAT, b2, ID_QUAT) assert len(c.residuals()) == 1 class TestAngle: def test_90_degrees(self): """90-deg angle between Z-axes rotated 90-deg about Y.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), ROT_90Y) c = AngleConstraint(b1, ID_QUAT, b2, ID_QUAT, math.pi / 2) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_0_degrees(self): """0-deg angle, same orientation → cos(0)=1, dot=1 → residual 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = AngleConstraint(b1, ID_QUAT, b2, ID_QUAT, 0.0) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = AngleConstraint(b1, ID_QUAT, b2, ID_QUAT, 1.0) assert len(c.residuals()) == 1 # ── Axis/surface constraints ───────────────────────────────────────── class TestConcentric: def test_coaxial(self): """Both on Z-axis → coaxial → residuals 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 5), (1, 0, 0, 0)) c = ConcentricConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_not_coaxial(self): """Offset in X → not coaxial.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (5, 0, 0), (1, 0, 0, 0)) c = ConcentricConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() vals = [r.eval(env) for r in c.residuals()] assert any(abs(v) > 0.1 for v in vals) def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = ConcentricConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 6 class TestTangent: def test_touching(self): """Marker origins at same point → tangent.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = TangentConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_separated(self): """Separated along normal → non-zero residual.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 5), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = TangentConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() assert abs(c.residuals()[0].eval(env) - 5.0) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = TangentConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 1 class TestPlanar: def test_coplanar(self): """Same plane, same orientation → all residuals 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (5, 3, 0), (1, 0, 0, 0)) c = PlanarConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_with_offset(self): """b_i at z=5, b_j at origin, normal=Z, offset=5. Signed distance = (p_i - p_j).n = 5, offset=5 → 5-5 = 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 5), (1, 0, 0, 0)) c = PlanarConstraint(b2, (0, 0, 0), ID_QUAT, b1, (0, 0, 0), ID_QUAT, offset=5.0) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = PlanarConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 4 class TestLineInPlane: def test_in_plane(self): """Line along X in XY plane → residuals 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) # b2 has Z-axis = (1,0,0) via 90-deg rotation about Y b2 = RigidBody("b", pt, (5, 0, 0), ROT_90Y) # Line = b2's Z-axis (which is world X), plane = b1's XY plane (normal=Z) c = LineInPlaneConstraint(b2, (0, 0, 0), ID_QUAT, b1, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = LineInPlaneConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 2 # ── Kinematic joints ───────────────────────────────────────────────── class TestBall: def test_same_as_coincident(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = BallConstraint(b1, (0, 0, 0), b2, (0, 0, 0)) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 assert len(c.residuals()) == 3 class TestRevolute: def test_satisfied(self): """Same position, same Z-axis → satisfied.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), ROT_90Z) # rotated about Z — still parallel c = RevoluteConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = RevoluteConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 6 class TestCylindrical: def test_on_axis(self): """Same axis, displaced along Z → satisfied.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 10), (1, 0, 0, 0)) c = CylindricalConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = CylindricalConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 6 class TestSlider: def test_aligned(self): """Same axis, no twist, displaced along Z → satisfied.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 10), (1, 0, 0, 0)) c = SliderConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_twisted(self): """Rotated about Z → twist residual non-zero.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), ROT_90Z) c = SliderConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() vals = [r.eval(env) for r in c.residuals()] # First 6 should be ~0 (parallel + on-line), but twist residual should be ~1 assert abs(vals[6]) > 0.5 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = SliderConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 7 class TestUniversal: def test_satisfied(self): """Same origin, perpendicular Z-axes.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), ROT_90Y) c = UniversalConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = UniversalConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT) assert len(c.residuals()) == 4 class TestScrew: def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = ScrewConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT, pitch=10.0) assert len(c.residuals()) == 7 def test_zero_displacement_zero_rotation(self): """Both at origin with identity rotation → all residuals 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = ScrewConstraint(b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT, pitch=10.0) env = pt.get_env() for r in c.residuals(): assert abs(r.eval(env)) < 1e-10 # ── Mechanical constraints ─────────────────────────────────────────── class TestGear: def test_both_at_rest(self): """Both at identity rotation → residual 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = GearConstraint(b1, ID_QUAT, b2, ID_QUAT, 1.0, 1.0) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = GearConstraint(b1, ID_QUAT, b2, ID_QUAT, 1.0, 2.0) assert len(c.residuals()) == 1 class TestRackPinion: def test_at_rest(self): """Both at rest → residual 0.""" pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = RackPinionConstraint( b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT, pitch_radius=5.0 ) env = pt.get_env() assert abs(c.residuals()[0].eval(env)) < 1e-10 def test_residual_count(self): pt = ParamTable() b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0)) b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0)) c = RackPinionConstraint( b1, (0, 0, 0), ID_QUAT, b2, (0, 0, 0), ID_QUAT, pitch_radius=1.0 ) assert len(c.residuals()) == 1 # ── Stubs ──────────────────────────────────────────────────────────── class TestStubs: def test_cam(self): assert CamConstraint().residuals() == [] def test_slot(self): assert SlotConstraint().residuals() == [] def test_distance_cyl_sph(self): assert DistanceCylSphConstraint().residuals() == []