diff --git a/dGame/dPropertyBehaviors/Strip.cpp b/dGame/dPropertyBehaviors/Strip.cpp index 153c8b4a..f65a32d7 100644 --- a/dGame/dPropertyBehaviors/Strip.cpp +++ b/dGame/dPropertyBehaviors/Strip.cpp @@ -209,6 +209,10 @@ void Strip::ProcNormalAction(float deltaTime, ModelComponent& modelComponent) { m_IsRotating = true; m_InActionTranslation.y = isSpinNegative ? -number : number; m_PreviousFrameRotation = entity.GetRotation(); + // compute the absolute rotation target quaternion + NiPoint3 deltaEuler = NiPoint3(0.0f, Math::DegToRad(m_InActionTranslation.y), 0.0f); + m_RotationTarget = m_PreviousFrameRotation; + m_RotationTarget *= NiQuaternion::FromEulerAngles(deltaEuler); // d/vi = t // radians/velocity = time // only care about the time, direction is irrelevant here @@ -223,6 +227,9 @@ void Strip::ProcNormalAction(float deltaTime, ModelComponent& modelComponent) { m_IsRotating = true; m_InActionTranslation.x = isRotateLeft ? -number : number; m_PreviousFrameRotation = entity.GetRotation(); + NiPoint3 deltaEuler = NiPoint3(Math::DegToRad(m_InActionTranslation.x), 0.0f, 0.0f); + m_RotationTarget = m_PreviousFrameRotation; + m_RotationTarget *= NiQuaternion::FromEulerAngles(deltaEuler); } } else if (nextActionType == "Roll" || nextActionType == "RollNegative") { const float radians = Math::DegToRad(number); @@ -234,6 +241,9 @@ void Strip::ProcNormalAction(float deltaTime, ModelComponent& modelComponent) { m_IsRotating = true; m_InActionTranslation.z = isRotateDown ? -number : number; m_PreviousFrameRotation = entity.GetRotation(); + NiPoint3 deltaEuler = NiPoint3(0.0f, 0.0f, Math::DegToRad(m_InActionTranslation.z)); + m_RotationTarget = m_PreviousFrameRotation; + m_RotationTarget *= NiQuaternion::FromEulerAngles(deltaEuler); } } /* END Rotate */ @@ -379,83 +389,37 @@ bool Strip::CheckRotation(float deltaTime, ModelComponent& modelComponent) { LOG("Velocity: x=%f, y=%f, z=%f", Math::RadToDeg(getAngVel.angVelocity.x) * deltaTime, Math::RadToDeg(getAngVel.angVelocity.y) * deltaTime, Math::RadToDeg(getAngVel.angVelocity.z) * deltaTime); m_PreviousFrameRotation = curRotation; - // Convert frame delta (radians) to absolute degrees moved this frame per axis. - // Use the reported angular velocity (radians/sec) * deltaTime instead of extracting - // Euler angles from the quaternion difference. Extracting Euler angles from a - // combined-axis quaternion won't produce per-axis rotations when axes rotate - // simultaneously, which caused late stopping. Using angular velocity is consistent - // with how velocity is applied in SimplePhysicsComponent. - NiPoint3 angMovedDegrees = NiPoint3(std::abs(Math::RadToDeg(getAngVel.angVelocity.x) * deltaTime), - std::abs(Math::RadToDeg(getAngVel.angVelocity.y) * deltaTime), - std::abs(Math::RadToDeg(getAngVel.angVelocity.z) * deltaTime)); - - const auto [rotateX, rotateY, rotateZ] = m_InActionTranslation; - bool rotateFinished = true; // assume finished until an axis proves otherwise - NiPoint3 finalRotationAdjustment = NiPoint3Constant::ZERO; - - // Use a small epsilon to avoid missing the exact-zero case due to floating point - constexpr float EPS_DEG = 1e-3f; - - // Handle each axis independently so we can rotate on multiple axes at once. - if (rotateX != 0.0f) { - m_InActionTranslation.x -= angMovedDegrees.x; - // Finished if we crossed zero or are within epsilon - if (std::signbit(m_InActionTranslation.x) != std::signbit(rotateX) || std::abs(m_InActionTranslation.x) <= EPS_DEG) { - finalRotationAdjustment.x = Math::DegToRad(m_InActionTranslation.x); - m_InActionTranslation.x = 0.0f; - } else { - rotateFinished = false; - } - } - - if (rotateY != 0.0f) { - m_InActionTranslation.y -= angMovedDegrees.y; - if (std::signbit(m_InActionTranslation.y) != std::signbit(rotateY) || std::abs(m_InActionTranslation.y) <= EPS_DEG) { - finalRotationAdjustment.y = Math::DegToRad(m_InActionTranslation.y); - m_InActionTranslation.y = 0.0f; - } else { - rotateFinished = false; - } - } - - if (rotateZ != 0.0f) { - m_InActionTranslation.z -= angMovedDegrees.z; - if (std::signbit(m_InActionTranslation.z) != std::signbit(rotateZ) || std::abs(m_InActionTranslation.z) <= EPS_DEG) { - finalRotationAdjustment.z = Math::DegToRad(m_InActionTranslation.z); - m_InActionTranslation.z = 0.0f; - } else { - rotateFinished = false; - } - } - - if (rotateFinished && (finalRotationAdjustment != NiPoint3Constant::ZERO)) { - LOG("Rotation finished, zeroing angVel for finished axes"); - - // Zero only the angular velocity channels that have just finished. - if (rotateX != 0.0f) getAngVel.angVelocity.x = 0.0f; - if (rotateY != 0.0f) getAngVel.angVelocity.y = 0.0f; - if (rotateZ != 0.0f) getAngVel.angVelocity.z = 0.0f; + // Use quaternion remaining angle to decide completion. Compute the quaternion + // that rotates from the current rotation to the target rotation. If the + // rotation angle of that quaternion is below an epsilon, we're finished. + NiQuaternion remaining = modelComponent.GetParent()->GetRotation().Diff(m_RotationTarget); + float w = remaining.w; + if (w > 1.0f) w = 1.0f; // clamp + if (w < -1.0f) w = -1.0f; + // angle (radians) = 2 * acos(w) + float angleRemainingRad = 2.0f * acos(w); + float angleRemainingDeg = Math::RadToDeg(angleRemainingRad); + constexpr float EPS_DEG = 0.1f; // finish when less than 0.1 degree remains + if (angleRemainingDeg <= EPS_DEG) { + LOG("Rotation finished by quaternion remaining angle (%f deg)", angleRemainingDeg); + // Zero angular velocity on axes that were part of this action (safe to zero all) + getAngVel.angVelocity = NiPoint3Constant::ZERO; GameMessages::SetAngularVelocity setAngVel{}; setAngVel.target = modelComponent.GetParent()->GetObjectID(); setAngVel.angVelocity = getAngVel.angVelocity; setAngVel.Send(); - // Do the final adjustment so we will have rotated exactly the requested units - auto currentRot = modelComponent.GetParent()->GetRotation(); - NiQuaternion finalAdjustment = NiQuaternion::FromEulerAngles(finalRotationAdjustment); - currentRot *= finalAdjustment; - currentRot.Normalize(); - modelComponent.GetParent()->SetRotation(currentRot); - - // If all axes are zeroed out then stop rotating - if (m_InActionTranslation == NiPoint3Constant::ZERO) { - m_IsRotating = false; - } + // Snap to exact target to avoid tiny residual error + modelComponent.GetParent()->SetRotation(m_RotationTarget); + m_InActionTranslation = NiPoint3Constant::ZERO; + m_IsRotating = false; + return true; } LOG("angVel: x=%f, y=%f, z=%f", m_InActionTranslation.x, m_InActionTranslation.y, m_InActionTranslation.z); - return rotateFinished; + // Not finished yet + return false; } void Strip::Update(float deltaTime, ModelComponent& modelComponent) { diff --git a/dGame/dPropertyBehaviors/Strip.h b/dGame/dPropertyBehaviors/Strip.h index 3fc8676d..90e503dd 100644 --- a/dGame/dPropertyBehaviors/Strip.h +++ b/dGame/dPropertyBehaviors/Strip.h @@ -78,6 +78,9 @@ private: NiQuaternion m_PreviousFrameRotation{}; + // The absolute target rotation for the current rotation action + NiQuaternion m_RotationTarget{}; + NiPoint3 m_SavedVelocity{}; }; diff --git a/tests/dGameTests/dPropertyBehaviorsTests/StripRotationTest.cpp b/tests/dGameTests/dPropertyBehaviorsTests/StripRotationTest.cpp index 45f86baa..5800625a 100644 --- a/tests/dGameTests/dPropertyBehaviorsTests/StripRotationTest.cpp +++ b/tests/dGameTests/dPropertyBehaviorsTests/StripRotationTest.cpp @@ -7,47 +7,24 @@ // Test that rotating a quaternion by 90 degrees on each axis in one frame // yields approximately 90 degrees when converted back to Euler angles. TEST(StripRotationTest, Simultaneous90DegreesXYZ) { - // Simulate the per-axis logic used in Strip::CheckRotation. - // Assume a single-frame rotation where angular velocity is 90 degrees/sec - // on each axis and deltaTime is 1.0 seconds. The remaining rotation - // prior to the frame is 90 degrees on each axis. - NiPoint3 remainingRotationDeg(90.0f, 90.0f, 90.0f); - NiPoint3 angularVelocityDegPerSec(90.0f, 90.0f, 90.0f); - const float deltaTime = 1.0f; + // Use quaternion math to verify a single-frame rotation of 90deg on each axis + // reaches the composed target. Start rotation is identity. + NiQuaternion start = NiQuaternionConstant::IDENTITY; + NiPoint3 targetEulerRad(Math::DegToRad(90.0f), Math::DegToRad(90.0f), Math::DegToRad(90.0f)); + NiQuaternion target = NiQuaternion::FromEulerAngles(targetEulerRad); - // Compute degrees moved this frame per axis - NiPoint3 angMovedDegrees(std::abs(angularVelocityDegPerSec.x) * deltaTime, - std::abs(angularVelocityDegPerSec.y) * deltaTime, - std::abs(angularVelocityDegPerSec.z) * deltaTime); + // Simulate applying angular velocity of 90deg/sec on each axis for 1 second + NiPoint3 appliedEulerRad = targetEulerRad; // angularVel * deltaTime + NiQuaternion afterFrame = start; + afterFrame *= NiQuaternion::FromEulerAngles(appliedEulerRad); - // Subtract movement from remaining rotation per axis (mirrors Strip logic) - bool rotateFinished = true; - constexpr float EPS_DEG = 1e-3f; + // Remaining quaternion from current to target should be identity (or near it) + NiQuaternion remaining = afterFrame.Diff(target); + float w = remaining.w; + if (w > 1.0f) w = 1.0f; + if (w < -1.0f) w = -1.0f; + float angleRemainingDeg = Math::RadToDeg(2.0f * acos(w)); - // X - remainingRotationDeg.x -= angMovedDegrees.x; - if (std::signbit(remainingRotationDeg.x) != std::signbit(90.0f) || std::abs(remainingRotationDeg.x) <= EPS_DEG) { - remainingRotationDeg.x = 0.0f; - } else { - rotateFinished = false; - } - - // Y - remainingRotationDeg.y -= angMovedDegrees.y; - if (std::signbit(remainingRotationDeg.y) != std::signbit(90.0f) || std::abs(remainingRotationDeg.y) <= EPS_DEG) { - remainingRotationDeg.y = 0.0f; - } else { - rotateFinished = false; - } - - // Z - remainingRotationDeg.z -= angMovedDegrees.z; - if (std::signbit(remainingRotationDeg.z) != std::signbit(90.0f) || std::abs(remainingRotationDeg.z) <= EPS_DEG) { - remainingRotationDeg.z = 0.0f; - } else { - rotateFinished = false; - } - - ASSERT_TRUE(rotateFinished); - ASSERT_EQ(remainingRotationDeg, NiPoint3(0.0f, 0.0f, 0.0f)); + // Allow a small residual due to floating point and composition order + ASSERT_LE(angleRemainingDeg, 0.2f); }