// Headless tests of the player's cybernetic arm (Player/Arm/): the generated layout, the joint maths, pose blending, // the reach solver, the jack-in sequence and the component that carries them. No renderer. #include "Tests/DescentTestCommon.h" #include "Core/Tuning/DescentTune_Arm.h" #include "Core/Tuning/DescentTune_Player.h" #include "Player/Arm/DescentArmDebugRig.h" #include "Player/Arm/DescentArmJack.h" #include "Player/Arm/DescentArmLayout.h" #include "Player/Arm/DescentArmPose.h" #include "Player/Arm/DescentArmReach.h" #include "Player/Arm/DescentArmViewmodelComponent.h" #include "Camera/CameraComponent.h" #include "Components/StaticMeshComponent.h" #if WITH_DEV_AUTOMATION_TESTS namespace { namespace ArmTestTune = Descent::Tune::Arm; /** The rest transform of every part in arm space, straight from the table (no pose code involved). */ void ArmTestRestChain(TArray& Out) { const TConstArrayView Parts = Descent::Arm::Parts(); Out.SetNum(Parts.Num()); for (int32 I = 0; I < Parts.Num(); ++I) { const FTransform Local(Parts[I].RestRotation, Parts[I].LocalOffsetCm); const int32 P = Parts[I].ParentIndex; Out[I] = (P >= 0 && P < I) ? Local * Out[P] : Local; } } /** Tip of a finger in the HAND's frame for a pose. */ FVector ArmTestFingertipInHand(const FDescentArmPose& Pose, int32 Finger) { FDescentArmTransforms W; Descent::Arm::ComputeWorld(Pose, W); const EDescentArmPart Last = Descent::Arm::FingerPart(Finger, 2); const FVector Tip = W[static_cast(Last)].TransformPosition(FVector(Descent::Arm::Def(Last).LocalBoundsMaxCm.X, 0.0, 0.0)); return W[static_cast(EDescentArmPart::Hand)].InverseTransformPosition(Tip); } bool ArmTestPoseInLimits(const FDescentArmPose& P) { const double Eps = 1e-6; // The shoulder is a ball joint: the upper arm within the allowed angle of straight ahead, any yaw / pitch that says so. const FVector Upper = Descent::Arm::ShoulderRotation(P.ShoulderYawDeg, P.ShoulderPitchDeg, 0.0).RotateVector(FVector::ForwardVector); return FMath::RadiansToDegrees(FMath::Acos(FMath::Clamp(Upper.X, -1.0, 1.0))) <= ArmTestTune::ShoulderMaxFromForwardDeg + 1e-4 && FMath::Abs(P.ShoulderRollDeg) <= ArmTestTune::ShoulderRollLimitDeg + Eps && P.ElbowFlexDeg >= ArmTestTune::ElbowMinFlexDeg - Eps && P.ElbowFlexDeg <= ArmTestTune::ElbowMaxFlexDeg + Eps && FMath::Abs(P.WristRollDeg) <= ArmTestTune::WristRollLimitDeg + Eps && P.WristFlexDeg >= ArmTestTune::WristFlexMinDeg - Eps && P.WristFlexDeg <= ArmTestTune::WristFlexMaxDeg + Eps; } FVector ArmTestDir(double YawDeg, double PitchDeg) { const double Y = FMath::DegreesToRadians(YawDeg), P = FMath::DegreesToRadians(PitchDeg); return FVector(FMath::Cos(P) * FMath::Cos(Y), FMath::Cos(P) * FMath::Sin(Y), FMath::Sin(P)); } /** The port and plug direction of the debug rig's first-person set-up, in ARM space, from the tuning constants alone. */ void ArmTestRigPort(FVector& OutPortArm, FVector& OutDirArm) { const double Drop = ArmTestTune::RigEyeHeightCm - ArmTestTune::RigPortHeightCm; const FRotator Look(-FMath::RadiansToDegrees(FMath::Atan2(Drop, ArmTestTune::RigWallDistanceCm)), 0.0, 0.0); const FTransform Eye(Look, FVector(0.0, 0.0, ArmTestTune::RigEyeHeightCm)); const FTransform Shoulder = FTransform(ArmTestTune::ShoulderOffsetCm) * Eye; OutPortArm = Shoulder.InverseTransformPosition(FVector(ArmTestTune::RigWallDistanceCm, 0.0, ArmTestTune::RigPortHeightCm)); OutDirArm = Shoulder.InverseTransformVectorNoScale(FVector::ForwardVector); } } // --------------------------------------------------------------------------------------------------------- // The generated layout is a valid chain // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmLayoutTest, "Descent.Arm.Layout", DESCENT_TEST_FLAGS) bool FDescentArmLayoutTest::RunTest(const FString& Parameters) { const TConstArrayView Parts = Descent::Arm::Parts(); if (!TestEqual(TEXT("21 parts: the shoulder anchor and 20 segments"), Parts.Num(), Descent::Arm::PartCount)) { return false; } // No part missing, and the table is in the order of EDescentArmPart (the C++ indexes it by that enum). const TCHAR* const Expected[] = { TEXT("RA_Root"), TEXT("RA_UpperArm"), TEXT("RA_Forearm"), TEXT("RA_PistonRod"), TEXT("RA_Wrist"), TEXT("RA_Hand"), TEXT("RA_Thumb_1"), TEXT("RA_Thumb_2"), TEXT("RA_Thumb_3"), TEXT("RA_Index_1"), TEXT("RA_Index_2"), TEXT("RA_Index_3"), TEXT("RA_Middle_1"), TEXT("RA_Middle_2"), TEXT("RA_Middle_3"), TEXT("RA_Ring_1"), TEXT("RA_Ring_2"), TEXT("RA_Ring_3"), TEXT("RA_Pinky_1"), TEXT("RA_Pinky_2"), TEXT("RA_Pinky_3") }; static_assert(UE_ARRAY_COUNT(Expected) == Descent::Arm::PartCount, "expected names out of step with EDescentArmPart"); // The chain the brief describes: who hangs on whom. const int32 ExpectedParent[] = { -1, 0, 1, 2, 2, 4, 5, 6, 7, 5, 9, 10, 5, 12, 13, 5, 15, 16, 5, 18, 19 }; int32 MeshCount = 0, Triangles = 0; for (int32 I = 0; I < Parts.Num(); ++I) { const FDescentArmPartDef& D = Parts[I]; TestEqual(*FString::Printf(TEXT("part %d is %s"), I, Expected[I]), FString(D.Name), FString(Expected[I])); TestEqual(*FString::Printf(TEXT("%s hangs on the right parent"), D.Name), D.ParentIndex, ExpectedParent[I]); TestTrue(*FString::Printf(TEXT("%s: the parent comes earlier in the table"), D.Name), D.ParentIndex < I); TestTrue(*FString::Printf(TEXT("%s: rest rotation is a unit quaternion"), D.Name), D.RestRotation.IsNormalized()); const bool bHasMesh = FCString::Strlen(D.MeshAsset) > 0; TestEqual(*FString::Printf(TEXT("%s: only the root has no mesh"), D.Name), bHasMesh, I != 0); MeshCount += bHasMesh ? 1 : 0; Triangles += D.Triangles; if (D.Joint != EDescentArmJoint::Fixed && D.Joint != EDescentArmJoint::Ball) { TestTrue(*FString::Printf(TEXT("%s: joint axis is a unit vector"), D.Name), FMath::IsNearlyEqual(D.JointAxis.Size(), 1.0, 1e-6)); } } TestEqual(TEXT("20 meshes"), MeshCount, 20); TestTrue(TEXT("the table carries the triangle counts"), Triangles > 10000); // Every child's pivot lies on its parent: inside the parent's mesh bounds (1 cm of slack, as in the export), in the // parent's own frame. The upper arm hangs on the root, which is a point: its pivot must BE that point. for (int32 I = 1; I < Parts.Num(); ++I) { const FDescentArmPartDef& D = Parts[I]; const FDescentArmPartDef& P = Parts[D.ParentIndex]; if (D.ParentIndex == 0) { TestTrue(*FString::Printf(TEXT("%s pivots on the shoulder"), D.Name), D.LocalOffsetCm.IsNearlyZero(1e-6)); continue; } const FBox Bounds = FBox(P.LocalBoundsMinCm, P.LocalBoundsMaxCm).ExpandBy(1.0); TestTrue(*FString::Printf(TEXT("%s: pivot (%s) lies on its parent %s"), D.Name, *D.LocalOffsetCm.ToString(), P.Name), Bounds.IsInsideOrOn(D.LocalOffsetCm)); } // The chain built from the local numbers lands every pivot where the model has it. TArray Rest; ArmTestRestChain(Rest); double WorstCm = 0.0; for (int32 I = 0; I < Parts.Num(); ++I) { WorstCm = FMath::Max(WorstCm, (Rest[I].GetLocation() - Parts[I].PivotRestCm).Size()); } TestTrue(*FString::Printf(TEXT("chained pivots match the model's (worst %.5f cm)"), WorstCm), WorstCm < 0.01); // The measured lengths agree with the table and with each other. TestTrue(TEXT("shoulder to elbow"), FMath::IsNearlyEqual(Parts[static_cast(EDescentArmPart::Forearm)].LocalOffsetCm.Size(), Descent::Arm::ShoulderToElbowCm(), 0.01)); const double ElbowToHand = (Parts[static_cast(EDescentArmPart::Hand)].PivotRestCm - Parts[static_cast(EDescentArmPart::Forearm)].PivotRestCm).Size(); TestTrue(TEXT("elbow to hand pivot"), FMath::IsNearlyEqual(ElbowToHand, Descent::Arm::ElbowToHandPivotCm(), 0.01)); TestTrue(TEXT("the three lengths add up to the whole"), FMath::IsNearlyEqual( Descent::Arm::ShoulderToElbowCm() + Descent::Arm::ElbowToHandPivotCm() + Descent::Arm::HandPivotToFingertipCm(), Descent::Arm::ShoulderToFingertipCm(), 0.01)); TestTrue(TEXT("about 0.78 m long (the brief)"), FMath::IsNearlyEqual(Descent::Arm::ShoulderToFingertipCm(), 78.0, 2.0)); // A right arm: with the palm down the thumb is on the left (-Y) of the hand. TestTrue(TEXT("the model is a right arm"), Descent::Arm::ModelIsRightArm()); TestTrue(TEXT("thumb root on the left of the hand"), Parts[static_cast(EDescentArmPart::Thumb1)].LocalOffsetCm.Y < -1.0); TestTrue(TEXT("pinky root on the right of the hand"), Parts[static_cast(EDescentArmPart::Pinky1)].LocalOffsetCm.Y > 1.0); return true; } // --------------------------------------------------------------------------------------------------------- // Parts move only about their joints, and in the right sense // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmJointsTest, "Descent.Arm.Joints", DESCENT_TEST_FLAGS) bool FDescentArmJointsTest::RunTest(const FString& Parameters) { using namespace Descent::Arm; const TConstArrayView Parts = Descent::Arm::Parts(); // A spread of poses: every named one and a few arbitrary ones. TArray Poses; for (int32 I = 0; I < static_cast(EDescentArmPose::Count); ++I) { Poses.Add(NamedPose(static_cast(I))); } FRandomStream Rng(20261005); for (int32 N = 0; N < 24; ++N) { FDescentArmPose P; P.ShoulderYawDeg = Rng.FRandRange(-90.f, 90.f); P.ShoulderPitchDeg = Rng.FRandRange(-80.f, 80.f); P.ShoulderRollDeg = Rng.FRandRange(-100.f, 100.f); P.ElbowFlexDeg = Rng.FRandRange(0.f, 150.f); P.WristRollDeg = Rng.FRandRange(-100.f, 100.f); P.WristFlexDeg = Rng.FRandRange(-65.f, 75.f); for (int32 F = 0; F < 5; ++F) { P.FingerCurl[F] = Rng.FRand(); } Poses.Add(P); } for (const FDescentArmPose& Pose : Poses) { FDescentArmTransforms Local; ComputeLocal(Pose, Local); for (int32 I = 0; I < Parts.Num(); ++I) { const FDescentArmPartDef& D = Parts[I]; const FVector Moved = Local[I].GetLocation() - D.LocalOffsetCm; const FQuat Turn = D.RestRotation.Inverse() * Local[I].GetRotation(); // what the joint added, in the part's own frame const double TurnDeg = FMath::RadiansToDegrees(Turn.GetAngle()); switch (D.Joint) { case EDescentArmJoint::Fixed: TestTrue(TEXT("the root only carries the shift"), Moved.Equals(Pose.ShiftCm, 1e-9) && TurnDeg < 1e-6); break; case EDescentArmJoint::Ball: TestTrue(TEXT("the shoulder turns, it does not move"), Moved.IsNearlyZero(1e-9)); break; case EDescentArmJoint::Slide: { const double Along = FVector::DotProduct(Moved, D.RestRotation.RotateVector(D.JointAxis)); TestTrue(TEXT("the rod does not turn"), TurnDeg < 1e-6); TestTrue(TEXT("the rod slides along its axis only"), (Moved - D.RestRotation.RotateVector(D.JointAxis) * Along).IsNearlyZero(1e-9)); TestTrue(TEXT("the rod stays inside its stroke"), Along <= 1e-9 && Along >= -ArmTestTune::RodStrokeCm - 1e-9); break; } default: { // Hinge, roll, finger: the pivot stays on the parent, the turn is about the joint's own axis. TestTrue(*FString::Printf(TEXT("%s: the pivot stays put"), D.Name), Moved.IsNearlyZero(1e-9)); if (TurnDeg > 1e-4 && TurnDeg < 360.0 - 1e-4) { const double Parallel = FMath::Abs(FVector::DotProduct(Turn.GetRotationAxis(), D.JointAxis)); TestTrue(*FString::Printf(TEXT("%s: turns about its joint axis (|cos| %.6f)"), D.Name, Parallel), Parallel > 1.0 - 1e-6); } break; } } } } // The rod follows the elbow: out when straight, in by the stroke when fully bent, never the wrong way. TestTrue(TEXT("rod out on a straight arm"), FMath::IsNearlyZero(RodSlideCm(0.0), 1e-9)); TestTrue(TEXT("rod in by its stroke at full bend"), FMath::IsNearlyEqual(RodSlideCm(ArmTestTune::ElbowMaxFlexDeg), -ArmTestTune::RodStrokeCm, 1e-9)); for (double Deg = 0.0; Deg < ArmTestTune::ElbowMaxFlexDeg; Deg += 10.0) { TestTrue(TEXT("rod travel is monotonic"), RodSlideCm(Deg + 10.0) < RodSlideCm(Deg)); } // Senses (Unreal axes, the arm at rest along +X, palm down): what the header promises. const FVector HandAtRest = EffectorPosition(FDescentArmPose(), EDescentArmEffector::HandPivot); TestTrue(TEXT("at zero the hand pivot is 59.2 cm straight ahead"), HandAtRest.Equals(FVector(MaxHandPivotReachCm(), 0.0, 0.0), 0.01)); { FDescentArmPose P; P.ShoulderPitchDeg = 30.0; TestTrue(TEXT("+pitch raises the arm"), EffectorPosition(P, EDescentArmEffector::HandPivot).Z > 20.0); } { FDescentArmPose P; P.ShoulderYawDeg = 30.0; TestTrue(TEXT("+yaw swings the arm right"), EffectorPosition(P, EDescentArmEffector::HandPivot).Y > 20.0); } { FDescentArmPose P; P.ElbowFlexDeg = 90.0; const FVector H = EffectorPosition(P, EDescentArmEffector::HandPivot); TestTrue(TEXT("+elbow folds the forearm up"), H.Z > 25.0 && FMath::IsNearlyEqual(H.X, ShoulderToElbowCm(), 0.01)); } { FDescentArmPose P; P.WristFlexDeg = 60.0; TestTrue(TEXT("+wrist flex bends the hand toward the palm (down)"), HandDirection(P).Z < -0.8); } { FDescentArmPose P; P.WristRollDeg = 90.0; FDescentArmTransforms W; ComputeWorld(P, W); const FVector Palm = W[static_cast(EDescentArmPart::Hand)].GetRotation().RotateVector(FVector(0.0, 0.0, -1.0)); const double ThumbZ = W[static_cast(EDescentArmPart::Thumb1)].GetLocation().Z; TestTrue(TEXT("+wrist roll is supination: palm faces left, thumb up"), Palm.Y < -0.99 && ThumbZ > HandAtRest.Z + 2.0); } // Fingers curl TOWARD THE PALM. In the hand's frame the palm is -Z: a curled tip is lower and nearer the wrist. for (int32 Finger = 1; Finger < 5; ++Finger) { FDescentArmPose Open, Half, Fist; Half.FingerCurl[Finger] = 0.5; Fist.FingerCurl[Finger] = 1.0; const FVector A = ArmTestFingertipInHand(Open, Finger), B = ArmTestFingertipInHand(Half, Finger), C = ArmTestFingertipInHand(Fist, Finger); TestTrue(*FString::Printf(TEXT("finger %d is straight at curl 0 (tip z %.2f)"), Finger, A.Z), FMath::Abs(A.Z) < 0.6); TestTrue(*FString::Printf(TEXT("finger %d curls toward the palm (tip z %.2f > %.2f > %.2f)"), Finger, A.Z, B.Z, C.Z), B.Z < A.Z - 2.0 && C.Z < A.Z - 1.0); TestTrue(*FString::Printf(TEXT("finger %d: a fist brings the tip back to the palm (x %.2f -> %.2f)"), Finger, A.X, C.X), C.X < A.X - 6.0 && C.Z < 0.0); TestTrue(*FString::Printf(TEXT("finger %d stays on its own side of the hand"), Finger), FMath::Abs(C.Y - A.Y) < 2.5); } { // The thumb closes across the palm: its tip ends nearer the palm's centre. FDescentArmPose Open, Tucked; Tucked.FingerCurl[0] = 1.0; const double DOpen = (ArmTestFingertipInHand(Open, 0) - ArmTestTune::PalmPointHandCm).Size(); const double DTucked = (ArmTestFingertipInHand(Tucked, 0) - ArmTestTune::PalmPointHandCm).Size(); TestTrue(*FString::Printf(TEXT("the thumb closes toward the palm (%.2f cm -> %.2f cm from its centre)"), DOpen, DTucked), DTucked < DOpen - 1.5); } // A finger's curl moves that finger alone. { FDescentArmPose A, B; B.FingerCurl[1] = 1.0; FDescentArmTransforms LA, LB; ComputeLocal(A, LA); ComputeLocal(B, LB); for (int32 I = 0; I < PartCount; ++I) { const bool bIndex = I >= static_cast(EDescentArmPart::Index1) && I <= static_cast(EDescentArmPart::Index3); TestEqual(*FString::Printf(TEXT("curling the index moves part %d: %s"), I, bIndex ? TEXT("yes") : TEXT("no")), !LA[I].Equals(LB[I], 1e-9), bIndex); } } return true; } // --------------------------------------------------------------------------------------------------------- // Named poses and blending // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmPoseBlendTest, "Descent.Arm.PoseBlend", DESCENT_TEST_FLAGS) bool FDescentArmPoseBlendTest::RunTest(const FString& Parameters) { using namespace Descent::Arm; // Seven named poses, each with a name that parses back, all different, all inside the joint limits. const int32 Count = static_cast(EDescentArmPose::Count); TestEqual(TEXT("seven named poses"), Count, 7); for (int32 I = 0; I < Count; ++I) { const EDescentArmPose Pose = static_cast(I); EDescentArmPose Parsed = EDescentArmPose::Count; TestTrue(*FString::Printf(TEXT("'%s' parses back"), PoseName(Pose)), ParsePose(PoseName(Pose), Parsed) && Parsed == Pose); TestTrue(*FString::Printf(TEXT("pose %s is inside the joint limits"), PoseName(Pose)), ArmTestPoseInLimits(NamedPose(Pose))); for (int32 J = I + 1; J < Count; ++J) { TestTrue(*FString::Printf(TEXT("%s and %s differ"), PoseName(Pose), PoseName(static_cast(J))), NamedPose(Pose).MaxDifference(NamedPose(static_cast(J))) > 5.0); } } EDescentArmPose Tmp = EDescentArmPose::Count; TestTrue(TEXT("'hold-tool' and 'HoldTool' parse"), ParsePose(TEXT("hold-tool"), Tmp) && Tmp == EDescentArmPose::HoldTool && ParsePose(TEXT("HoldTool"), Tmp)); TestFalse(TEXT("an unknown name does not parse"), ParsePose(TEXT("wave"), Tmp)); // The hand shapes the names promise. TestTrue(TEXT("point: index straight, the others closed"), NamedPose(EDescentArmPose::Point).FingerCurl[1] < 0.1 && NamedPose(EDescentArmPose::Point).FingerCurl[2] > 0.8); TestTrue(TEXT("plug: four fingers straight"), NamedPose(EDescentArmPose::Plug).FingerCurl[1] < 0.05 && NamedPose(EDescentArmPose::Plug).FingerCurl[4] < 0.05); TestTrue(TEXT("grip: fingers closed around something"), NamedPose(EDescentArmPose::Grip).FingerCurl[2] > 0.4); const FDescentArmPose& A = NamedPose(EDescentArmPose::Idle); const FDescentArmPose& B = NamedPose(EDescentArmPose::Grip); TestTrue(TEXT("Lerp(A, B, 0) is A"), FDescentArmPose::Lerp(A, B, 0.0).Equals(A, 0.0)); TestTrue(TEXT("Lerp(A, B, 1) is B"), FDescentArmPose::Lerp(A, B, 1.0).Equals(B, 1e-12)); // Deterministic: the same calls give the same numbers, bit for bit. auto Run = [&](double Dt, int32 Steps) { FDescentArmBlender Bl; Bl.Reset(A); Bl.BlendTo(B, 0.35); for (int32 I = 0; I < Steps; ++I) { Bl.Advance(Dt); } return Bl; }; TestTrue(TEXT("two identical runs agree exactly"), Run(1.0 / 30.0, 5).Current().Equals(Run(1.0 / 30.0, 5).Current(), 0.0)); // ... and it depends on the time that has passed, not on how it was cut into frames. const FDescentArmPose At30 = Run(1.0 / 30.0, 6).Current(); // 0.2 s const FDescentArmPose At120 = Run(1.0 / 120.0, 24).Current(); // 0.2 s TestTrue(*FString::Printf(TEXT("0.2 s in 6 steps equals 0.2 s in 24 steps (difference %.3g)"), At30.MaxDifference(At120)), At30.Equals(At120, 1e-9)); TestTrue(TEXT("the blend arrives exactly"), Run(1.0 / 30.0, 30).Current().Equals(B, 0.0) && !Run(1.0 / 30.0, 30).IsBlending()); TestTrue(TEXT("half way in time is half way in weight (smoothstep)"), FMath::IsNearlyEqual(Run(0.175, 1).Weight(), 0.5, 1e-9)); // Smooth: the weight never goes back, starts and ends gently, and no step jumps. { FDescentArmBlender Bl; Bl.Reset(A); Bl.BlendTo(B, 0.35); double LastW = 0.0, MaxStep = 0.0; FDescentArmPose Last = Bl.Current(); const double Whole = A.MaxDifference(B); for (int32 I = 0; I < 42; ++I) { Bl.Advance(1.0 / 120.0); TestTrue(TEXT("the weight is monotonic"), Bl.Weight() >= LastW - 1e-12); LastW = Bl.Weight(); MaxStep = FMath::Max(MaxStep, Bl.Current().MaxDifference(Last)); Last = Bl.Current(); } // A smoothstep's fastest step is 1.5 x the average one. TestTrue(*FString::Printf(TEXT("no step is larger than 1.5 x the mean step (max %.3f of %.3f)"), MaxStep, Whole), MaxStep <= 1.5 * Whole / 42.0 * 1.02); } // A new target in mid-blend starts from where the arm is: no jump. { FDescentArmBlender Bl = Run(1.0 / 30.0, 4); const FDescentArmPose Mid = Bl.Current(); Bl.BlendTo(NamedPose(EDescentArmPose::Point), 0.35); TestTrue(TEXT("re-targeting does not move the arm"), Bl.Current().Equals(Mid, 0.0)); Bl.Advance(1.0 / 120.0); TestTrue(TEXT("...and the next step is small"), Bl.Current().MaxDifference(Mid) < 0.5); } // A zero duration snaps. { FDescentArmBlender Bl; Bl.Reset(A); Bl.BlendTo(B, 0.0); TestTrue(TEXT("duration 0 snaps"), Bl.Current().Equals(B, 0.0) && !Bl.IsBlending()); } return true; } // --------------------------------------------------------------------------------------------------------- // The reach solver // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmReachTest, "Descent.Arm.Reach", DESCENT_TEST_FLAGS) bool FDescentArmReachTest::RunTest(const FString& Parameters) { using namespace Descent::Arm; const double Tol = ArmTestTune::ReachToleranceCm; const FDescentArmPose Base = NamedPose(EDescentArmPose::Plug); const double TipLen = EffectorOffsetInHand(Base, EDescentArmEffector::Fingertip).Size(); // 1. Inside the range, the hand lands on the target within the stated accuracy. // The range: every point the hand pivot can be at (shoulder distance between the folded and the straight arm) in the // 60-degree cone in front of the shoulder; for the palm and the fingertip the hand points along the line of sight. int32 Solved = 0, Exact = 0; double WorstErr = 0.0, WorstDir = 0.0, WorstTwist = 0.0; const double MinD = MinHandPivotReachCm() + 1.0, MaxD = MaxHandPivotReachCm() - 0.5; const EDescentArmEffector Effectors[] = { EDescentArmEffector::HandPivot, EDescentArmEffector::Palm, EDescentArmEffector::Fingertip }; for (const EDescentArmEffector Eff : Effectors) { const double Extra = EffectorOffsetInHand(Base, Eff).Size(); for (double Yaw = -60.0; Yaw <= 60.0; Yaw += 20.0) { for (double Pitch = -60.0; Pitch <= 60.0; Pitch += 20.0) { for (int32 K = 0; K <= 6; ++K) { const FVector Dir = ArmTestDir(Yaw, Pitch); FDescentArmReachRequest Req; Req.Effector = Eff; Req.bAimAtTarget = true; // The effector sticks out along the hand, which points along Dir: the pivot is then at MinD..MaxD. Req.TargetCm = Dir * (FMath::Lerp(MinD, MaxD, K / 6.0) + Extra * 0.98); const FDescentArmReachResult R = SolveReach(Req, Base); ++Solved; WorstErr = FMath::Max(WorstErr, R.ErrorCm); WorstDir = FMath::Max(WorstDir, R.HandDirErrorDeg); WorstTwist = FMath::Max(WorstTwist, R.TwistErrorDeg); Exact += (R.HandDirErrorDeg < 0.01 && R.TwistErrorDeg < 0.01) ? 1 : 0; if (!R.bReachable || R.ErrorCm > Tol) { AddError(FString::Printf(TEXT("in range but missed: effector %d target (%s) error %.4f cm limited %d"), static_cast(Eff), *Req.TargetCm.ToString(), R.ErrorCm, R.bLimited ? 1 : 0)); } // The report is honest: the error is what the forward kinematics of the returned pose gives. const double Fk = (EffectorPosition(R.Pose, Eff) - Req.TargetCm).Size(); TestTrue(TEXT("ErrorCm is the forward-kinematics error of the returned pose"), FMath::IsNearlyEqual(Fk, R.ErrorCm, 1e-9)); TestTrue(TEXT("the returned pose respects the joint limits"), ArmTestPoseInLimits(R.Pose)); } } } } AddInfo(FString::Printf(TEXT("REACH ACCURACY: %d targets inside the range, worst position error %.3g cm (stated accuracy %.2f cm); hand direction and palm twist also exact in %d of them (worst %.2f / %.2f deg elsewhere)"), Solved, WorstErr, Tol, Exact, WorstDir, WorstTwist)); TestTrue(*FString::Printf(TEXT("worst error inside the range %.3g cm <= stated %.2f cm"), WorstErr, Tol), WorstErr <= Tol); // 2. With a fixed hand direction and palm twist (how the game asks: "fingers into the port, palm down"). { int32 N = 0, Hit = 0, Oriented = 0; double Worst = 0.0; for (double X = 35.0; X <= 65.0; X += 10.0) { for (double Y = -30.0; Y <= 20.0; Y += 10.0) { for (double Z = -20.0; Z <= 30.0; Z += 10.0) { for (const double Twist : { 0.0, 20.0, 60.0, 90.0 }) { FDescentArmReachRequest Req; Req.TargetCm = FVector(X, Y, Z); Req.HandDirection = FVector::ForwardVector; Req.PalmTwistDeg = Twist; Req.Effector = EDescentArmEffector::Fingertip; const FDescentArmReachResult R = SolveReach(Req, Base); ++N; Hit += R.bReachable ? 1 : 0; Oriented += (R.bReachable && R.HandDirErrorDeg < 0.01 && R.TwistErrorDeg < 0.01) ? 1 : 0; if (R.bReachable) { Worst = FMath::Max(Worst, R.ErrorCm); } // Whether the target is within the arm's length for this hand direction is plain geometry. const double PivotDist = (Req.TargetCm - FVector::ForwardVector * TipLen).Size(); if (PivotDist > MinHandPivotReachCm() + 1.5 && PivotDist < MaxHandPivotReachCm() - 1.5 && !R.bReachable) { AddError(FString::Printf(TEXT("fingertip target (%s) twist %.0f is within the arm's length but was missed by %.3f cm"), *Req.TargetCm.ToString(), Twist, R.ErrorCm)); } } } } } AddInfo(FString::Printf(TEXT("REACH, fingers forward, 4 palm twists: %d targets in a 30 x 50 x 50 cm box, %d reached (worst %.3g cm), %d of those with direction and twist exact"), N, Hit, Worst, Oriented)); TestTrue(TEXT("most of the box in front of the shoulder is reached with the exact orientation"), Oriented > N / 2); } // 3. Out of range: it says so, with the true shortfall, and the arm stretches toward the target. const double MaxTip = MaxHandPivotReachCm() + TipLen; for (const double Dist : { 85.0, 120.0, 300.0 }) { for (const FVector& Dir : { ArmTestDir(0.0, 0.0), ArmTestDir(35.0, 20.0), ArmTestDir(-40.0, -30.0) }) { FDescentArmReachRequest Req; Req.bAimAtTarget = true; Req.TargetCm = Dir * Dist; const FDescentArmReachResult R = SolveReach(Req, Base); TestFalse(*FString::Printf(TEXT("%.0f cm away is reported as out of range"), Dist), R.bReachable); TestTrue(TEXT("...and as held back"), R.bLimited); TestTrue(*FString::Printf(TEXT("...distance %.1f > max reach %.1f"), R.DistanceCm, R.MaxReachCm), R.DistanceCm > R.MaxReachCm && FMath::IsNearlyEqual(R.MaxReachCm, MaxTip, 1e-6)); TestTrue(*FString::Printf(TEXT("...the reported error %.2f cm is the shortfall %.2f cm"), R.ErrorCm, Dist - MaxTip), FMath::IsNearlyEqual(R.ErrorCm, Dist - MaxTip, 0.6)); TestTrue(TEXT("...and equals the forward-kinematics error"), FMath::IsNearlyEqual((EffectorPosition(R.Pose, Req.Effector) - Req.TargetCm).Size(), R.ErrorCm, 1e-9)); TestTrue(TEXT("...with the arm straight"), R.Pose.ElbowFlexDeg < 1.0); } } // Too close: the elbow cannot fold that far. { FDescentArmReachRequest Req; Req.Effector = EDescentArmEffector::HandPivot; Req.TargetCm = FVector(4.0, 0.0, 0.0); const FDescentArmReachResult R = SolveReach(Req, Base); TestTrue(*FString::Printf(TEXT("a pivot target 4 cm from the shoulder is out of range (error %.2f cm)"), R.ErrorCm), !R.bReachable && R.ErrorCm > 5.0 && R.bLimited); } // Just inside and just outside the far edge. { FDescentArmReachRequest Req; Req.bAimAtTarget = true; Req.TargetCm = ArmTestDir(10.0, 5.0) * (MaxTip - 0.2); TestTrue(TEXT("2 mm inside the far edge is reached"), SolveReach(Req, Base).bReachable); Req.TargetCm = ArmTestDir(10.0, 5.0) * (MaxTip + 0.5); const FDescentArmReachResult R = SolveReach(Req, Base); TestTrue(*FString::Printf(TEXT("5 mm outside the far edge is not (error %.3f cm)"), R.ErrorCm), !R.bReachable && FMath::IsNearlyEqual(R.ErrorCm, 0.5, 0.1)); } // 4. Deterministic and stateless. { FDescentArmReachRequest Req; Req.TargetCm = FVector(52.0, -11.0, 14.0); Req.HandDirection = ArmTestDir(-8.0, 12.0); Req.PalmTwistDeg = 30.0; const FDescentArmReachResult A = SolveReach(Req, Base); SolveReach(FDescentArmReachRequest(), NamedPose(EDescentArmPose::Grip)); // something else in between const FDescentArmReachResult B = SolveReach(Req, Base); TestTrue(TEXT("the same request gives the same pose, bit for bit"), A.Pose.Equals(B.Pose, 0.0) && A.ErrorCm == B.ErrorCm); TestTrue(TEXT("finger curls come from the base pose"), A.Pose.FingerCurl[0] == Base.FingerCurl[0] && A.Pose.FingerCurl[3] == Base.FingerCurl[3]); } // 5. The shift of a pose moves the shoulder, and the solver knows. { FDescentArmPose Shifted = Base; Shifted.ShiftCm = FVector(-3.0, 4.0, -10.0); FDescentArmReachRequest Req; Req.TargetCm = FVector(50.0, 0.0, 0.0); const FDescentArmReachResult R = SolveReach(Req, Shifted); TestTrue(TEXT("a shifted shoulder still lands on the target"), R.bReachable && (EffectorPosition(R.Pose, Req.Effector) - Req.TargetCm).Size() <= Tol); } // 6. The named poses are reach results: report how well each recipe was met. { struct FRow { EDescentArmPose Pose; const ArmTestTune::FPoseRecipe* Recipe; }; const FRow Rows[] = { { EDescentArmPose::Idle, &ArmTestTune::IdleRecipe }, { EDescentArmPose::Reach, &ArmTestTune::ReachRecipe }, { EDescentArmPose::Grip, &ArmTestTune::GripRecipe }, { EDescentArmPose::Point, &ArmTestTune::PointRecipe }, { EDescentArmPose::Plug, &ArmTestTune::PlugRecipe }, { EDescentArmPose::HoldTool, &ArmTestTune::HoldRecipe } }; for (const FRow& Row : Rows) { const FDescentArmPose& P = NamedPose(Row.Pose); const FVector Want(Row.Recipe->HandX, Row.Recipe->HandY, Row.Recipe->HandZ); const double PosErr = (EffectorPosition(P, EDescentArmEffector::HandPivot) - Want).Size(); FDescentArmTransforms W; ComputeWorld(P, W); const FVector Elbow = W[static_cast(EDescentArmPart::Forearm)].GetLocation(); const FVector Dir = HandDirection(P); AddInfo(FString::Printf(TEXT("pose %-9s hand pivot off by %.4f cm; elbow at (%.1f, %.1f, %.1f); hand points yaw %.0f pitch %.0f; joints yaw %.1f pitch %.1f roll %.1f elbow %.1f wrist roll %.1f flex %.1f"), PoseName(Row.Pose), PosErr, Elbow.X, Elbow.Y, Elbow.Z, FMath::RadiansToDegrees(FMath::Atan2(Dir.Y, Dir.X)), FMath::RadiansToDegrees(FMath::Asin(Dir.Z)), P.ShoulderYawDeg, P.ShoulderPitchDeg, P.ShoulderRollDeg, P.ElbowFlexDeg, P.WristRollDeg, P.WristFlexDeg)); TestTrue(*FString::Printf(TEXT("pose %s puts the hand where its recipe says (%.4f cm)"), PoseName(Row.Pose), PosErr), PosErr <= Tol); TestTrue(*FString::Printf(TEXT("pose %s has the wrist its recipe says"), PoseName(Row.Pose)), P.WristRollDeg == Row.Recipe->WristRollDeg && P.WristFlexDeg == Row.Recipe->WristFlexDeg); // A right arm: the elbow hangs below the hand and to the outside (the right) of the line shoulder -> hand, never inside the body. const FVector Line = Want.GetSafeNormal(); const FVector Off = Elbow - Line * FVector::DotProduct(Elbow, Line); TestTrue(*FString::Printf(TEXT("pose %s: the elbow is below and outside the line shoulder -> hand (%.1f, %.1f, %.1f)"), PoseName(Row.Pose), Off.X, Off.Y, Off.Z), Off.Z < -3.0 && Off.Y > 0.0); } // The elbow parameter's sense: 0 hangs straight below, +90 is straight out to the right. const FDescentArmPose Down = PoseFromHandAndElbow(FVector(45.0, 0.0, 0.0), 0.0, 0.0, 0.0, FDescentArmPose()); const FDescentArmPose Out = PoseFromHandAndElbow(FVector(45.0, 0.0, 0.0), 90.0, 0.0, 0.0, FDescentArmPose()); FDescentArmTransforms WD, WO; ComputeWorld(Down, WD); ComputeWorld(Out, WO); const FVector ED = WD[static_cast(EDescentArmPart::Forearm)].GetLocation(), EO = WO[static_cast(EDescentArmPart::Forearm)].GetLocation(); TestTrue(*FString::Printf(TEXT("elbow 0 hangs straight below (%.1f, %.1f, %.1f)"), ED.X, ED.Y, ED.Z), FMath::Abs(ED.Y) < 0.01 && ED.Z < -10.0); TestTrue(*FString::Printf(TEXT("elbow +90 is out to the right (%.1f, %.1f, %.1f)"), EO.X, EO.Y, EO.Z), EO.Y > 10.0 && FMath::Abs(EO.Z) < 0.01); TestTrue(TEXT("...and the hand pivot stays on its point either way"), EffectorPosition(Down, EDescentArmEffector::HandPivot).Equals(FVector(45.0, 0.0, 0.0), 1e-6) && EffectorPosition(Out, EDescentArmEffector::HandPivot).Equals(FVector(45.0, 0.0, 0.0), 1e-6)); } return true; } // --------------------------------------------------------------------------------------------------------- // The jack-in sequence // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmJackTest, "Descent.Arm.Jack", DESCENT_TEST_FLAGS) bool FDescentArmJackTest::RunTest(const FString& Parameters) { using namespace Descent::Arm; // The design's times (DESIGN_UE.md 6.9: approach 0-0.7 s, feed 1.0-4.1 s, release at 4.8 s). TestEqual(TEXT("the approach ends at 0.7 s"), ArmTestTune::JackReachEnd, 0.7); TestEqual(TEXT("the feed begins at 1.0 s"), ArmTestTune::JackPlugEnd, 1.0); TestEqual(TEXT("the feed ends at 4.1 s"), ArmTestTune::JackFeedEnd, 4.1); TestEqual(TEXT("released at 4.8 s"), ArmTestTune::JackTotal, 4.8); // All six phases, in order. const EDescentArmJackPhase Order[] = { EDescentArmJackPhase::Raise, EDescentArmJackPhase::Reach, EDescentArmJackPhase::Plug, EDescentArmJackPhase::Feed, EDescentArmJackPhase::Withdraw, EDescentArmJackPhase::Lower, EDescentArmJackPhase::Done }; const double Times[] = { 0.1, 0.5, 0.85, 2.5, 4.3, 4.6, 4.8 }; for (int32 I = 0; I < UE_ARRAY_COUNT(Order); ++I) { TestEqual(*FString::Printf(TEXT("phase at %.2f s is %s"), Times[I], JackPhaseName(Order[I])), static_cast(JackPhaseAt(Times[I])), static_cast(Order[I])); } TestEqual(TEXT("before the start: inactive"), static_cast(JackPhaseAt(-0.1)), static_cast(EDescentArmJackPhase::Inactive)); FDescentArmJackInput In; In.Start = NamedPose(EDescentArmPose::Hidden); In.End = NamedPose(EDescentArmPose::Hidden); ArmTestRigPort(In.PortCm, In.PlugDirection); AddInfo(FString::Printf(TEXT("JACK geometry (debug rig): port at (%s) cm from the shoulder, %.1f cm away, plug direction (%s)"), *In.PortCm.ToString(), In.PortCm.Size(), *In.PlugDirection.ToString())); // Pure function of time. TestTrue(TEXT("the same time gives the same pose"), EvaluateJack(0.6, In).Pose.Equals(EvaluateJack(0.6, In).Pose, 0.0)); TestTrue(TEXT("it starts in the start pose"), EvaluateJack(0.0, In).Pose.Equals(In.Start, 1e-9)); TestTrue(TEXT("it ends in the end pose"), EvaluateJack(4.8, In).Pose.Equals(In.End, 0.0) && EvaluateJack(9.0, In).Pose.Equals(In.End, 0.0)); // Walk the whole sequence at 120 Hz: every phase appears, the feed window is exact, the plug sits on the socket for // all of it, the hand comes in along the port's axis, and nothing jumps. const double Dt = 1.0 / 120.0; FDescentArmPose Last = EvaluateJack(0.0, In).Pose; double MaxStepDeg = 0.0, MaxStepAt = 0.0, WorstFeedErr = 0.0, WorstFeedDir = 0.0, WorstAxisOff = 0.0, FeedSeconds = 0.0; int32 PhaseSeen[8] = { 0 }; for (int32 I = 1; I * Dt <= ArmTestTune::JackTotal + 1e-9; ++I) { const double T = I * Dt; const FDescentArmJackFrame F = EvaluateJack(T, In); ++PhaseSeen[static_cast(F.Phase)]; const double Step = F.Pose.MaxDifference(Last); if (Step > MaxStepDeg) { MaxStepDeg = Step; MaxStepAt = T; } Last = F.Pose; const FVector Tip = EffectorPosition(F.Pose, EDescentArmEffector::Fingertip); if (F.Phase == EDescentArmJackPhase::Feed) { FeedSeconds += Dt; TestTrue(TEXT("feeding is flagged during the feed"), F.bFeeding); WorstFeedErr = FMath::Max(WorstFeedErr, (Tip - In.PortCm).Size()); WorstFeedDir = FMath::Max(WorstFeedDir, FMath::RadiansToDegrees(FMath::Acos(FMath::Clamp(FVector::DotProduct(HandDirection(F.Pose), In.PlugDirection), -1.0, 1.0)))); TestTrue(TEXT("the frame reports the same error"), FMath::IsNearlyEqual(F.ReachErrorCm, (Tip - In.PortCm).Size(), 1e-9) && F.bReachable); } else { TestFalse(TEXT("no feeding outside the feed"), F.bFeeding); } if (F.Phase == EDescentArmJackPhase::Plug || F.Phase == EDescentArmJackPhase::Withdraw) { // Straight in and straight out: the fingertip stays on the port's axis. const FVector Rel = Tip - In.PortCm; WorstAxisOff = FMath::Max(WorstAxisOff, (Rel - In.PlugDirection * FVector::DotProduct(Rel, In.PlugDirection)).Size()); TestTrue(TEXT("the plug is in front of the socket, never past it"), FVector::DotProduct(Rel, In.PlugDirection) <= ArmTestTune::ReachToleranceCm); } } for (int32 P = static_cast(EDescentArmJackPhase::Raise); P <= static_cast(EDescentArmJackPhase::Lower); ++P) { TestTrue(*FString::Printf(TEXT("phase %s happens"), JackPhaseName(static_cast(P))), PhaseSeen[P] > 0); } TestTrue(*FString::Printf(TEXT("the feed lasts 3.1 s (%.3f)"), FeedSeconds), FMath::IsNearlyEqual(FeedSeconds, 3.1, 2.0 * Dt)); AddInfo(FString::Printf(TEXT("JACK ACCURACY: fingertip on the socket within %.3g cm for the whole feed (stated %.2f cm), hand off the port's axis direction by at most %.3f deg; off the axis by at most %.3g cm while plugging and withdrawing; largest joint step at 120 Hz %.2f (at %.3f s)"), WorstFeedErr, ArmTestTune::ReachToleranceCm, WorstFeedDir, WorstAxisOff, MaxStepDeg, MaxStepAt)); TestTrue(*FString::Printf(TEXT("plug on the socket during the feed (%.3g cm)"), WorstFeedErr), WorstFeedErr <= ArmTestTune::ReachToleranceCm); TestTrue(*FString::Printf(TEXT("the hand enters along the port's axis (%.3f deg off)"), WorstFeedDir), WorstFeedDir < 1.0); TestTrue(*FString::Printf(TEXT("the plug travels on the port's axis (%.3g cm off)"), WorstAxisOff), WorstAxisOff <= ArmTestTune::ReachToleranceCm); // The fastest honest movement is the raise (a joint turning 150 degrees in 0.3 s, easing in, moves 8 degrees in its last // 1/120 s). A solver that switched branch would jump by tens of degrees. TestTrue(*FString::Printf(TEXT("no joint jumps: largest step %.2f at %.3f s"), MaxStepDeg, MaxStepAt), MaxStepDeg < 12.0); // A port the arm cannot reach: the sequence still runs, and says the plug is not on the socket. { FDescentArmJackInput Far = In; Far.PortCm = In.PortCm + In.PlugDirection * 40.0; const FDescentArmJackFrame F = EvaluateJack(2.0, Far); TestTrue(*FString::Printf(TEXT("a port 40 cm too far is reported unreachable (error %.1f cm)"), F.ReachErrorCm), !F.bReachable && F.ReachErrorCm > 20.0); } return true; } // --------------------------------------------------------------------------------------------------------- // The component: real scene components in a world // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmComponentTest, "Descent.Arm.Component", DESCENT_TEST_FLAGS) bool FDescentArmComponentTest::RunTest(const FString& Parameters) { using namespace Descent::Arm; FDescentTestWorld TW; ADescentArmDebugRig* Rig = ADescentArmDebugRig::FindOrSpawn(TW.Get(), EDescentArmRigMode::Showcase); UDescentArmViewmodelComponent* Arm = Rig ? Rig->GetArm() : nullptr; if (!TestNotNull(TEXT("rig and arm component"), Arm)) { return false; } Arm->bTickSelf = false; // the test owns time // Built from the layout: 21 components in the model's chain, 20 with their mesh. TestTrue(TEXT("parts are built"), Arm->ArePartsBuilt()); TestEqual(TEXT("20 segments have their mesh (run Tools\\import_assets.ps1 if not)"), Arm->CountPartsWithMesh(), 20); const TConstArrayView Parts = Descent::Arm::Parts(); for (int32 I = 0; I < Parts.Num(); ++I) { USceneComponent* C = Arm->GetPartComponent(static_cast(I)); if (!TestNotNull(*FString::Printf(TEXT("component of %s"), Parts[I].Name), C)) { return false; } USceneComponent* WantParent = Parts[I].ParentIndex < 0 ? static_cast(Arm) : Arm->GetPartComponent(static_cast(Parts[I].ParentIndex)); TestTrue(*FString::Printf(TEXT("%s is attached to its parent in the chain"), Parts[I].Name), C->GetAttachParent() == WantParent); } // The components are where the pose maths says, for every named pose. auto WorstOffset = [&]() { FDescentArmTransforms W; ComputeWorld(Arm->GetCurrentPose(), W); double Worst = 0.0; for (int32 I = 0; I < PartCount; ++I) { const FTransform Want = W[I] * Arm->GetComponentTransform(); const FTransform Got = Arm->GetPartComponent(static_cast(I))->GetComponentTransform(); // Three points of the part, 10 cm apart: where they are says both where the part is and how it is turned (cm). for (const FVector& Probe : { FVector::ZeroVector, FVector(10.0, 0.0, 0.0), FVector(0.0, 10.0, 0.0) }) { Worst = FMath::Max(Worst, (Want.TransformPosition(Probe) - Got.TransformPosition(Probe)).Size()); } } return Worst; }; for (int32 I = 0; I < static_cast(EDescentArmPose::Count); ++I) { const EDescentArmPose Pose = static_cast(I); Arm->SetPose(Pose, 0.3); TestTrue(TEXT("a blend is running"), Arm->GetBlender().IsBlending() || Arm->GetCurrentPose().Equals(NamedPose(Pose), 1e-9)); Arm->Advance(0.15); TestTrue(*FString::Printf(TEXT("mid-blend to %s the components follow the pose (%.4f)"), PoseName(Pose), WorstOffset()), WorstOffset() < 0.01); Arm->Advance(0.2); TestTrue(*FString::Printf(TEXT("the arm arrives in %s"), PoseName(Pose)), Arm->GetCurrentPose().Equals(NamedPose(Pose), 1e-9) && Arm->GetNamedPose() == Pose && !Arm->IsCustomPose()); TestTrue(*FString::Printf(TEXT("in %s the components are on the pose (%.4f)"), PoseName(Pose), WorstOffset()), WorstOffset() < 0.01); } // A finger curl on top of a pose. Arm->SnapToPose(EDescentArmPose::Idle); Arm->SetFingerCurl(1, 1.0, 0.1); Arm->Advance(0.2); TestTrue(TEXT("SetFingerCurl closes that finger and marks the pose custom"), FMath::IsNearlyEqual(Arm->GetCurrentPose().FingerCurl[1], 1.0, 1e-9) && Arm->IsCustomPose() && FMath::IsNearlyEqual(Arm->GetCurrentPose().FingerCurl[2], NamedPose(EDescentArmPose::Idle).FingerCurl[2], 1e-9)); // A reach in the world: the real fingertip component ends on the target. { const FVector TargetWorld = Arm->ArmToWorld(FVector(55.0, -8.0, 12.0)); const FDescentArmReachResult R = Arm->ReachToWorld(TargetWorld, Arm->GetComponentTransform().TransformVectorNoScale(FVector::ForwardVector), EDescentArmEffector::Fingertip, 0.2); Arm->Advance(0.25); const USceneComponent* Tip = Arm->GetPartComponent(EDescentArmPart::Middle3); const FVector TipWorld = Tip->GetComponentTransform().TransformPosition(FVector(Def(EDescentArmPart::Middle3).LocalBoundsMaxCm.X, 0.0, 0.0)); TestTrue(*FString::Printf(TEXT("ReachToWorld: the fingertip component is %.4f cm from the target"), (TipWorld - TargetWorld).Size()), R.bReachable && (TipWorld - TargetWorld).Size() <= ArmTestTune::ReachToleranceCm + 0.001); } // First person: mounted on the camera at the right shoulder, first-person primitives, no shadow, stencil 1, channels 0 and 1. Rig->SetMode(EDescentArmRigMode::FirstPerson); TestTrue(TEXT("mounted on the camera"), Arm->IsMountedOnCamera() && Arm->GetAttachParent() == Rig->GetCamera()); TestTrue(TEXT("at the right shoulder: to the right of and below the eye"), Arm->GetRelativeLocation().Equals(ArmTestTune::ShoulderOffsetCm, 1e-6) && Arm->GetRelativeLocation().Y > 10.0 && Arm->GetRelativeLocation().Z < -10.0); TestTrue(TEXT("the rig's camera has the game's field of view"), FMath::IsNearlyEqual(static_cast(Rig->GetCamera()->FieldOfView), ArmTestTune::GameFovDeg, 1e-4)); for (int32 I = 1; I < PartCount; ++I) { const UStaticMeshComponent* M = Arm->GetPartMesh(static_cast(I)); if (!TestNotNull(TEXT("mesh component"), M)) { return false; } const bool bOk = M->FirstPersonPrimitiveType == EFirstPersonPrimitiveType::FirstPerson && !M->CastShadow && M->bRenderCustomDepth && M->CustomDepthStencilValue == ArmTestTune::DryStencilValue && M->LightingChannels.bChannel0 && M->LightingChannels.bChannel1 && !M->LightingChannels.bChannel2 && M->GetCollisionEnabled() == ECollisionEnabled::NoCollision; TestTrue(*FString::Printf(TEXT("%s carries the first-person settings"), Parts[I].Name), bOk); } Arm->SetSide(EDescentArmSide::Left); TestTrue(TEXT("a left arm is the model mirrored, at the left shoulder"), Arm->GetRelativeScale3D().Y < 0.0 && Arm->GetRelativeLocation().Y < -10.0); Arm->SetSide(EDescentArmSide::Right); // The whole jack-in against the rig's port, at 30 Hz like the game. Arm->SnapToPose(EDescentArmPose::Hidden); int32 Plugged = 0, Finished = 0; Arm->OnJackPlugged.AddLambda([&Plugged]() { ++Plugged; }); Arm->OnJackFinished.AddLambda([&Finished]() { ++Finished; }); TestTrue(TEXT("the jack-in starts"), Rig->StartJack() && Arm->IsJackActive()); TestFalse(TEXT("a second one is refused while it runs"), Rig->StartJack()); const USceneComponent* Tip = Arm->GetPartComponent(EDescentArmPart::Middle3); double WorstFeedCm = 0.0, PluggedAt = -1.0, Elapsed = 0.0; int32 Steps = 0, FeedSteps = 0; while (Arm->IsJackActive() && Steps < 400) { Arm->SetPose(EDescentArmPose::Point); // ignored: the sequence owns the arm Arm->Advance(1.0 / 30.0); Elapsed += 1.0 / 30.0; ++Steps; if (Plugged == 1 && PluggedAt < 0.0) { PluggedAt = Elapsed; } if (Arm->IsJackFeeding()) { ++FeedSteps; const FVector TipWorld = Tip->GetComponentTransform().TransformPosition(FVector(Def(EDescentArmPart::Middle3).LocalBoundsMaxCm.X, 0.0, 0.0)); WorstFeedCm = FMath::Max(WorstFeedCm, (TipWorld - Rig->GetPortWorldCm()).Size()); } } AddInfo(FString::Printf(TEXT("JACK on the component: %d steps of 1/30 s = %.3f s, plugged at %.3f s, %d feed steps, fingertip component within %.4f cm of the port in the world"), Steps, Elapsed, PluggedAt, FeedSteps, WorstFeedCm)); TestTrue(*FString::Printf(TEXT("the sequence takes 4.8 s (%.3f)"), Elapsed), FMath::IsNearlyEqual(Elapsed, 4.8, 1.0 / 30.0 + 1e-6)); TestTrue(*FString::Printf(TEXT("the plug seats at 1.0 s (%.3f)"), PluggedAt), FMath::IsNearlyEqual(PluggedAt, 1.0, 1.0 / 30.0 + 1e-6)); TestEqual(TEXT("plugged fires once"), Plugged, 1); TestEqual(TEXT("finished fires once"), Finished, 1); TestTrue(*FString::Printf(TEXT("the fingertip component sits on the port during the feed (%.4f cm)"), WorstFeedCm), FeedSteps > 80 && WorstFeedCm <= ArmTestTune::ReachToleranceCm + 0.001); TestTrue(TEXT("it ends hidden, as it began"), Arm->GetNamedPose() == EDescentArmPose::Hidden && Arm->GetCurrentPose().Equals(NamedPose(EDescentArmPose::Hidden), 1e-9)); // Abort in mid-sequence hands the arm back. TestTrue(TEXT("restart"), Rig->StartJack()); Arm->Advance(2.0); Arm->AbortJackIn(EDescentArmPose::Idle); TestTrue(TEXT("abort stops the sequence and blends to the pose asked for"), !Arm->IsJackActive() && Arm->GetNamedPose() == EDescentArmPose::Idle); TestEqual(TEXT("an aborted sequence does not report finished"), Finished, 1); return true; } // --------------------------------------------------------------------------------------------------------- // First person: nothing through the near plane, nothing into a wall. Parts do not pass through each other. // --------------------------------------------------------------------------------------------------------- namespace { /** Do two boxes (each given in its part's own frame, shrunk by Shrink on every side) overlap? Separating-axis test. */ bool ArmTestBoxesOverlap(const FTransform& TA, const FVector& MinA, const FVector& MaxA, const FTransform& TB, const FVector& MinB, const FVector& MaxB, double Shrink) { const FVector CA = TA.TransformPosition((MinA + MaxA) * 0.5), CB = TB.TransformPosition((MinB + MaxB) * 0.5); const FVector EA = ((MaxA - MinA) * 0.5 - FVector(Shrink)).ComponentMax(FVector(0.01)), EB = ((MaxB - MinB) * 0.5 - FVector(Shrink)).ComponentMax(FVector(0.01)); const FVector AxA[3] = { TA.GetUnitAxis(EAxis::X), TA.GetUnitAxis(EAxis::Y), TA.GetUnitAxis(EAxis::Z) }; const FVector AxB[3] = { TB.GetUnitAxis(EAxis::X), TB.GetUnitAxis(EAxis::Y), TB.GetUnitAxis(EAxis::Z) }; const FVector D = CB - CA; auto Separated = [&](const FVector& L) { const double Len = L.Size(); if (Len < 1e-9) { return false; } const FVector N = L / Len; const double RA = EA.X * FMath::Abs(FVector::DotProduct(AxA[0], N)) + EA.Y * FMath::Abs(FVector::DotProduct(AxA[1], N)) + EA.Z * FMath::Abs(FVector::DotProduct(AxA[2], N)); const double RB = EB.X * FMath::Abs(FVector::DotProduct(AxB[0], N)) + EB.Y * FMath::Abs(FVector::DotProduct(AxB[1], N)) + EB.Z * FMath::Abs(FVector::DotProduct(AxB[2], N)); return FMath::Abs(FVector::DotProduct(D, N)) > RA + RB; }; for (int32 I = 0; I < 3; ++I) { if (Separated(AxA[I]) || Separated(AxB[I])) { return false; } } for (int32 I = 0; I < 3; ++I) { for (int32 J = 0; J < 3; ++J) { if (Separated(FVector::CrossProduct(AxA[I], AxB[J]))) { return false; } } } return true; } /** Pairs of segments whose boxes overlap in a pose, as text ("" when none). Joined segments and neighbours on one knuckle row are not pairs. */ FString ArmTestOverlaps(const FDescentArmPose& Pose, double Shrink) { using namespace Descent::Arm; FDescentArmTransforms W; ComputeWorld(Pose, W); FString Out; auto Check = [&](EDescentArmPart A, EDescentArmPart B) { const FDescentArmPartDef& DA = Def(A); const FDescentArmPartDef& DB = Def(B); if (ArmTestBoxesOverlap(W[static_cast(A)], DA.LocalBoundsMinCm, DA.LocalBoundsMaxCm, W[static_cast(B)], DB.LocalBoundsMinCm, DB.LocalBoundsMaxCm, Shrink)) { Out += FString::Printf(TEXT("%s x %s; "), DA.Name, DB.Name); } }; // The palm: the hand's box behind the knuckle row. (The whole box reaches 1.9 cm past the pinky's knuckle, because the // knuckle row is slanted and the middle finger's guard sticks out; a pinky bending at its own knuckle is not "in the hand".) const FDescentArmPartDef& HandDef = Def(EDescentArmPart::Hand); double KnuckleX = HandDef.LocalBoundsMaxCm.X; for (int32 F = 1; F < FingerCount; ++F) { KnuckleX = FMath::Min(KnuckleX, Def(FingerPart(F, 0)).LocalOffsetCm.X); } const FVector PalmMax(KnuckleX - 0.3, HandDef.LocalBoundsMaxCm.Y, HandDef.LocalBoundsMaxCm.Z); auto CheckPalm = [&](EDescentArmPart A) { const FDescentArmPartDef& DA = Def(A); if (ArmTestBoxesOverlap(W[static_cast(A)], DA.LocalBoundsMinCm, DA.LocalBoundsMaxCm, W[static_cast(EDescentArmPart::Hand)], HandDef.LocalBoundsMinCm, PalmMax, Shrink)) { Out += FString::Printf(TEXT("%s x palm; "), DA.Name); } }; for (int32 F = 0; F < FingerCount; ++F) { for (int32 J = 0; J < 3; ++J) { const EDescentArmPart P = FingerPart(F, J); // A fingertip into the palm. Not the thumb: it lies ACROSS the underside of the palm, where its turned box and // the palm's box overlap although the parts do not (looked at instead: arm_showcase thumb_tucked_front / _palm, // where a gap shows between the palm and the fully tucked thumb). if (J > 0 && F > 0) { CheckPalm(P); } Check(P, EDescentArmPart::Forearm); // the hand folded back onto the forearm Check(P, EDescentArmPart::UpperArm); // the hand into the upper arm at a deep elbow bend // The four fingers curl side by side and their first joints share the knuckle row; the thumb crosses them. for (int32 G = 1; F == 0 && J > 0 && G < FingerCount; ++G) { for (int32 K = 0; K < 3; ++K) { Check(P, FingerPart(G, K)); } } } } Check(EDescentArmPart::Hand, EDescentArmPart::UpperArm); Check(EDescentArmPart::Wrist, EDescentArmPart::UpperArm); return Out; } } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmFitTest, "Descent.Arm.FirstPersonFit", DESCENT_TEST_FLAGS) bool FDescentArmFitTest::RunTest(const FString& Parameters) { using namespace Descent::Arm; namespace PlayerTune = Descent::Tune::Player; const double Scale = ArmTestTune::FirstPersonScale; const double WallCm = PlayerTune::CapsuleRadiusM * 100.0; // the nearest a wall's face can come to the eye const double NearCm = GNearClippingPlane; const double TanH = FMath::Tan(FMath::DegreesToRadians(PlayerTune::FovDeg * 0.5)); const double TanV = TanH * 9.0 / 16.0; // the picture is 16:9; a narrower one sees less of the arm // Every pose the arm rests in outside the jack-in, and the way between them (the blends pass through other places). TArray Poses; TArray Names; const int32 Count = static_cast(EDescentArmPose::Count); for (int32 I = 0; I < Count; ++I) { for (int32 J = 0; J < Count; ++J) { for (int32 K = (I == J ? 0 : 1); K <= (I == J ? 0 : 7); ++K) { Poses.Add(FDescentArmPose::Lerp(NamedPose(static_cast(I)), NamedPose(static_cast(J)), K / 8.0)); Names.Add(I == J ? FString(PoseName(static_cast(I))) : FString::Printf(TEXT("%s to %s at %d/8"), PoseName(static_cast(I)), PoseName(static_cast(J)), K)); } } } double DeepestCm = 0.0, NearestSeenCm = 1.0e9; FString DeepestIn, NearestIn; for (int32 N = 0; N < Poses.Num(); ++N) { FDescentArmTransforms W; ComputeWorld(Poses[N], W); for (int32 I = 1; I < PartCount; ++I) { const FDescentArmPartDef& D = Def(static_cast(I)); // A 5 x 5 x 5 lattice through the segment's box: its corners bound the depth, its inside finds what is on screen. for (int32 Cell = 0; Cell < 125; ++Cell) { const FVector Local(FMath::Lerp(D.LocalBoundsMinCm.X, D.LocalBoundsMaxCm.X, (Cell % 5) / 4.0), FMath::Lerp(D.LocalBoundsMinCm.Y, D.LocalBoundsMaxCm.Y, ((Cell / 5) % 5) / 4.0), FMath::Lerp(D.LocalBoundsMinCm.Z, D.LocalBoundsMaxCm.Z, (Cell / 25) / 4.0)); const FVector Eye = W[I].TransformPosition(Local) + ArmTestTune::ShoulderOffsetCm; // the eye's frame: +X into the picture if (Eye.X > DeepestCm) { DeepestCm = Eye.X; DeepestIn = Names[N]; } const bool bOnScreen = Eye.X > 0.0 && FMath::Abs(Eye.Y) <= Eye.X * TanH && FMath::Abs(Eye.Z) <= Eye.X * TanV; if (bOnScreen && Eye.X < NearestSeenCm) { NearestSeenCm = Eye.X; NearestIn = Names[N]; } } } } AddInfo(FString::Printf(TEXT("FIRST PERSON FIT at scale %.2f: the deepest point of the arm is %.1f cm from the eye (%s), drawn at %.1f cm; a wall's face is never nearer than %.1f cm. The nearest point on screen is %.1f cm away (%s), drawn at %.1f cm; the near plane is at %.1f cm."), Scale, DeepestCm, *DeepestIn, DeepestCm * Scale, WallCm, NearestSeenCm, *NearestIn, NearestSeenCm * Scale, NearCm)); TestTrue(*FString::Printf(TEXT("the arm is drawn short of any wall the body can touch (%.1f cm, wall at %.1f cm)"), DeepestCm * Scale, WallCm), DeepestCm * Scale < WallCm - 1.0); TestTrue(*FString::Printf(TEXT("nothing on screen is cut by the near plane (%.1f cm, plane at %.1f cm)"), NearestSeenCm * Scale, NearCm), NearestSeenCm * Scale > NearCm + 0.5); TestTrue(TEXT("during the jack-in the depth is true"), ArmTestTune::JackFirstPersonScale == 1.0); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentArmClearanceTest, "Descent.Arm.Clearance", DESCENT_TEST_FLAGS) bool FDescentArmClearanceTest::RunTest(const FString& Parameters) { using namespace Descent::Arm; // Boxes are bigger than the parts they hold, so "no boxes overlap" is stronger than "no parts overlap". 2 mm of each // box's skin is ignored (rounded knuckles do not fill their corners). const double Shrink = 0.2; for (int32 I = 0; I < static_cast(EDescentArmPose::Count); ++I) { const EDescentArmPose Pose = static_cast(I); const FString Hits = ArmTestOverlaps(NamedPose(Pose), Shrink); TestTrue(*FString::Printf(TEXT("pose %s: no segment passes through another (%s)"), PoseName(Pose), Hits.IsEmpty() ? TEXT("none") : *Hits), Hits.IsEmpty()); } // Each finger alone over its whole curl, then the four fingers closing together. for (int32 Finger = 0; Finger <= 5; ++Finger) { double FirstHit = -1.0; FString What; for (int32 K = 0; K <= 100 && FirstHit < 0.0; ++K) { FDescentArmPose P; for (int32 F = 0; F < 5; ++F) { P.FingerCurl[F] = ((Finger == 5 && F > 0) || Finger == F) ? K / 100.0 : 0.0; } What = ArmTestOverlaps(P, Shrink); if (!What.IsEmpty()) { FirstHit = K / 100.0; } } const FString Who = Finger == 5 ? FString(TEXT("the four fingers closing together")) : FString::Printf(TEXT("finger %d curling from 0 to 1"), Finger); TestTrue(*FString::Printf(TEXT("%s: no segment enters another (first contact at curl %.2f: %s)"), *Who, FirstHit, *What), FirstHit < 0.0); } // A fist: the thumb cannot tuck all the way under fingers that are closed too (it would be where their tips are). How far // it can go is measured here, and the fist the showcase photographs (ArmTestTune::FistThumbCurl) must be inside that. { double ThumbFree = 1.0; FString What; for (int32 K = 0; K <= 100; ++K) { FDescentArmPose P; for (int32 F = 1; F < 5; ++F) { P.FingerCurl[F] = 1.0; } P.FingerCurl[0] = K / 100.0; What = ArmTestOverlaps(P, Shrink); if (!What.IsEmpty()) { ThumbFree = (K - 1) / 100.0; break; } } AddInfo(FString::Printf(TEXT("FIST: with the four fingers closed the thumb is clear of them up to curl %.2f (then: %s); the fist uses %.2f"), ThumbFree, *What, ArmTestTune::FistThumbCurl)); TestTrue(*FString::Printf(TEXT("the fist's thumb curl %.2f is inside the clear range (up to %.2f)"), ArmTestTune::FistThumbCurl, ThumbFree), ArmTestTune::FistThumbCurl <= ThumbFree); } // The elbow and the wrist at their limits. { FDescentArmPose P; P.ElbowFlexDeg = ArmTestTune::ElbowMaxFlexDeg; FString Hits = ArmTestOverlaps(P, Shrink); TestTrue(*FString::Printf(TEXT("elbow fully bent: the hand stays clear of the upper arm (%s)"), *Hits), Hits.IsEmpty()); P.WristFlexDeg = ArmTestTune::WristFlexMaxDeg; Hits = ArmTestOverlaps(P, Shrink); TestTrue(*FString::Printf(TEXT("elbow fully bent and wrist fully flexed (%s)"), *Hits), Hits.IsEmpty()); P.WristFlexDeg = ArmTestTune::WristFlexMinDeg; Hits = ArmTestOverlaps(P, Shrink); TestTrue(*FString::Printf(TEXT("elbow fully bent and wrist fully bent back (%s)"), *Hits), Hits.IsEmpty()); } return true; } #endif // WITH_DEV_AUTOMATION_TESTS