461 lines
19 KiB
C#
461 lines
19 KiB
C#
|
|
#if UNITY_EDITOR
|
||
|
|
using System.Globalization;
|
||
|
|
using System.Text;
|
||
|
|
using Sirenix.OdinInspector;
|
||
|
|
using Sirenix.OdinInspector.Editor;
|
||
|
|
using UnityEditor;
|
||
|
|
using UnityEngine;
|
||
|
|
|
||
|
|
namespace SLSUtilities.FunctionalAnimation
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// 用于分析 AnimationClip 指定时间区间内 Root Motion 位移与旋转的编辑器工具。
|
||
|
|
/// 工具只读取动画曲线,不会修改 AnimationClip 或其它资产。
|
||
|
|
/// </summary>
|
||
|
|
public class RootMotionCalibrationWindow : OdinEditorWindow
|
||
|
|
{
|
||
|
|
private const float MinimumSampleRate = 30f;
|
||
|
|
private const float MinimumIntervalDuration = 0.0001f;
|
||
|
|
|
||
|
|
[Title("Root Motion Calibration")]
|
||
|
|
[ShowInInspector, AssetsOnly, PropertyOrder(-100)]
|
||
|
|
[LabelText("Animation Clip")]
|
||
|
|
[OnValueChanged(nameof(OnAnimationClipChanged))]
|
||
|
|
private AnimationClip animationClip;
|
||
|
|
|
||
|
|
[ShowInInspector, ReadOnly, PropertyOrder(-99)]
|
||
|
|
[LabelText("Clip Duration")]
|
||
|
|
private float ClipDuration => animationClip != null ? animationClip.length : 0f;
|
||
|
|
|
||
|
|
[ShowInInspector, ReadOnly, PropertyOrder(-98)]
|
||
|
|
[LabelText("Frame Rate")]
|
||
|
|
private float FrameRate => animationClip != null ? animationClip.frameRate : 0f;
|
||
|
|
|
||
|
|
[ShowInInspector, PropertyOrder(-90)]
|
||
|
|
[OnInspectorGUI]
|
||
|
|
private void DrawIntervalControls()
|
||
|
|
{
|
||
|
|
if (animationClip == null)
|
||
|
|
{
|
||
|
|
EditorGUILayout.HelpBox("请拖入一个 AnimationClip。", MessageType.Info);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
float duration = Mathf.Max(animationClip.length, MinimumIntervalDuration);
|
||
|
|
float start = Mathf.Clamp(analysisRange.x, 0f, duration);
|
||
|
|
float end = Mathf.Clamp(analysisRange.y, 0f, duration);
|
||
|
|
|
||
|
|
EditorGUILayout.LabelField("Analysis Interval", EditorStyles.boldLabel);
|
||
|
|
|
||
|
|
EditorGUILayout.BeginHorizontal();
|
||
|
|
EditorGUILayout.LabelField("Start", GUILayout.Width(45f));
|
||
|
|
start = EditorGUILayout.FloatField(start);
|
||
|
|
EditorGUILayout.LabelField("End", GUILayout.Width(35f));
|
||
|
|
end = EditorGUILayout.FloatField(end);
|
||
|
|
EditorGUILayout.EndHorizontal();
|
||
|
|
|
||
|
|
EditorGUILayout.MinMaxSlider("Range", ref start, ref end, 0f, duration);
|
||
|
|
|
||
|
|
EditorGUILayout.BeginHorizontal();
|
||
|
|
if (GUILayout.Button("Whole Clip"))
|
||
|
|
{
|
||
|
|
start = 0f;
|
||
|
|
end = duration;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (GUILayout.Button("Analyze"))
|
||
|
|
{
|
||
|
|
analysisRange = new Vector2(start, end);
|
||
|
|
Analyze();
|
||
|
|
}
|
||
|
|
|
||
|
|
EditorGUILayout.EndHorizontal();
|
||
|
|
|
||
|
|
start = Mathf.Clamp(start, 0f, duration);
|
||
|
|
end = Mathf.Clamp(end, 0f, duration);
|
||
|
|
if (end < start)
|
||
|
|
{
|
||
|
|
(start, end) = (end, start);
|
||
|
|
}
|
||
|
|
|
||
|
|
analysisRange = new Vector2(start, end);
|
||
|
|
DrawIntervalPreview(duration, start, end);
|
||
|
|
}
|
||
|
|
|
||
|
|
[ShowInInspector, PropertyOrder(-80)]
|
||
|
|
[ReadOnly, LabelText("Curve Status")]
|
||
|
|
private string CurveStatus => string.IsNullOrEmpty(curveStatus) ? "尚未分析" : curveStatus;
|
||
|
|
|
||
|
|
[ShowInInspector, PropertyOrder(-70)]
|
||
|
|
[ReadOnly, LabelText("Interval")]
|
||
|
|
private string IntervalText =>
|
||
|
|
$"{analysisRange.x.ToString("F4", CultureInfo.InvariantCulture)} s - " +
|
||
|
|
$"{analysisRange.y.ToString("F4", CultureInfo.InvariantCulture)} s";
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result", Order = 0)]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Interval Duration")]
|
||
|
|
private float IntervalDuration => result.intervalDuration;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Start Position")]
|
||
|
|
private Vector3 StartPosition => result.startPosition;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("End Position")]
|
||
|
|
private Vector3 EndPosition => result.endPosition;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Displacement")]
|
||
|
|
private Vector3 Displacement => result.displacement;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Horizontal Displacement")]
|
||
|
|
private Vector3 HorizontalDisplacement => result.horizontalDisplacement;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Path Distance")]
|
||
|
|
private float PathDistance => result.pathDistance;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Average Velocity")]
|
||
|
|
private Vector3 AverageVelocity => result.averageVelocity;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Average Horizontal Speed")]
|
||
|
|
private float AverageHorizontalSpeed => result.averageHorizontalSpeed;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Peak Horizontal Speed")]
|
||
|
|
private float PeakHorizontalSpeed => result.peakHorizontalSpeed;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Rotation Delta")]
|
||
|
|
private float RotationDelta => result.rotationDelta;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[ShowInInspector, ReadOnly, LabelText("Average Angular Speed")]
|
||
|
|
private float AverageAngularSpeed => result.averageAngularSpeed;
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[Button("Copy Result")]
|
||
|
|
private void CopyResult()
|
||
|
|
{
|
||
|
|
if (!hasAnalysis)
|
||
|
|
{
|
||
|
|
Analyze();
|
||
|
|
}
|
||
|
|
|
||
|
|
GUIUtility.systemCopyBuffer = BuildResultText();
|
||
|
|
}
|
||
|
|
|
||
|
|
[TitleGroup("Root Motion Result")]
|
||
|
|
[Button("Copy CSV Row")]
|
||
|
|
private void CopyCsvRow()
|
||
|
|
{
|
||
|
|
if (!hasAnalysis)
|
||
|
|
{
|
||
|
|
Analyze();
|
||
|
|
}
|
||
|
|
|
||
|
|
GUIUtility.systemCopyBuffer = BuildCsvRow();
|
||
|
|
}
|
||
|
|
|
||
|
|
private Vector2 analysisRange;
|
||
|
|
private string curveStatus;
|
||
|
|
private bool hasAnalysis;
|
||
|
|
private CalibrationResult result;
|
||
|
|
private RootMotionCurves curves;
|
||
|
|
|
||
|
|
[MenuItem("Tools/SLS Utilities/Functional Animation/Root Motion Calibration")]
|
||
|
|
private static void OpenWindow()
|
||
|
|
{
|
||
|
|
var window = GetWindow<RootMotionCalibrationWindow>();
|
||
|
|
window.titleContent = new GUIContent("Root Motion Calibration");
|
||
|
|
window.Show();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 双击 AnimationClip 时直接打开校准窗口,并自动填入该动画。
|
||
|
|
/// </summary>
|
||
|
|
[UnityEditor.Callbacks.OnOpenAsset(1)]
|
||
|
|
private static bool OnOpenAsset(int instanceId, int line)
|
||
|
|
{
|
||
|
|
var clip = EditorUtility.InstanceIDToObject(instanceId) as AnimationClip;
|
||
|
|
if (clip == null)
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
OpenWindow();
|
||
|
|
var window = GetWindow<RootMotionCalibrationWindow>();
|
||
|
|
window.animationClip = clip;
|
||
|
|
window.OnAnimationClipChanged();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnAnimationClipChanged()
|
||
|
|
{
|
||
|
|
hasAnalysis = false;
|
||
|
|
result = default;
|
||
|
|
curveStatus = string.Empty;
|
||
|
|
|
||
|
|
if (animationClip == null)
|
||
|
|
{
|
||
|
|
analysisRange = Vector2.zero;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
analysisRange = new Vector2(0f, animationClip.length);
|
||
|
|
Analyze();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void DrawIntervalPreview(float duration, float start, float end)
|
||
|
|
{
|
||
|
|
Rect rect = GUILayoutUtility.GetRect(GUIContent.none, GUIStyle.none, GUILayout.Height(28f));
|
||
|
|
EditorGUI.DrawRect(rect, new Color(0.18f, 0.18f, 0.18f));
|
||
|
|
|
||
|
|
float startX = Mathf.Lerp(rect.x, rect.xMax, start / duration);
|
||
|
|
float endX = Mathf.Lerp(rect.x, rect.xMax, end / duration);
|
||
|
|
EditorGUI.DrawRect(
|
||
|
|
new Rect(startX, rect.y + 4f, Mathf.Max(1f, endX - startX), rect.height - 8f),
|
||
|
|
new Color(0.25f, 0.65f, 1f, 0.8f));
|
||
|
|
|
||
|
|
GUI.Label(new Rect(rect.x + 4f, rect.y + 4f, rect.width - 8f, rect.height - 8f),
|
||
|
|
"Selected Root Motion Interval", EditorStyles.centeredGreyMiniLabel);
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Analyze()
|
||
|
|
{
|
||
|
|
if (animationClip == null)
|
||
|
|
{
|
||
|
|
hasAnalysis = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
float duration = Mathf.Max(animationClip.length, MinimumIntervalDuration);
|
||
|
|
float start = Mathf.Clamp(analysisRange.x, 0f, duration);
|
||
|
|
float end = Mathf.Clamp(analysisRange.y, 0f, duration);
|
||
|
|
if (end < start)
|
||
|
|
{
|
||
|
|
(start, end) = (end, start);
|
||
|
|
}
|
||
|
|
|
||
|
|
analysisRange = new Vector2(start, end);
|
||
|
|
curves = RootMotionCurves.Read(animationClip);
|
||
|
|
curveStatus = curves.GetStatusText();
|
||
|
|
|
||
|
|
if (!curves.HasPosition)
|
||
|
|
{
|
||
|
|
hasAnalysis = false;
|
||
|
|
result = default;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
result = CalibrationResult.Calculate(curves, start, end, animationClip.frameRate);
|
||
|
|
hasAnalysis = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private string BuildResultText()
|
||
|
|
{
|
||
|
|
var builder = new StringBuilder();
|
||
|
|
builder.AppendLine($"Animation Clip: {animationClip.name}");
|
||
|
|
builder.AppendLine($"Interval: {IntervalText}");
|
||
|
|
builder.AppendLine($"Interval Duration: {result.intervalDuration:F4} s");
|
||
|
|
builder.AppendLine($"Start Position: {FormatVector(result.startPosition)}");
|
||
|
|
builder.AppendLine($"End Position: {FormatVector(result.endPosition)}");
|
||
|
|
builder.AppendLine($"Displacement: {FormatVector(result.displacement)}");
|
||
|
|
builder.AppendLine($"Horizontal Displacement: {FormatVector(result.horizontalDisplacement)}");
|
||
|
|
builder.AppendLine($"Path Distance: {result.pathDistance:F4} m");
|
||
|
|
builder.AppendLine($"Average Velocity: {FormatVector(result.averageVelocity)} m/s");
|
||
|
|
builder.AppendLine($"Average Horizontal Speed: {result.averageHorizontalSpeed:F4} m/s");
|
||
|
|
builder.AppendLine($"Peak Horizontal Speed: {result.peakHorizontalSpeed:F4} m/s");
|
||
|
|
builder.AppendLine($"Rotation Delta: {result.rotationDelta:F4} deg");
|
||
|
|
builder.AppendLine($"Average Angular Speed: {result.averageAngularSpeed:F4} deg/s");
|
||
|
|
return builder.ToString();
|
||
|
|
}
|
||
|
|
|
||
|
|
private string BuildCsvRow()
|
||
|
|
{
|
||
|
|
return string.Join(",",
|
||
|
|
animationClip.name,
|
||
|
|
analysisRange.x.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
analysisRange.y.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.intervalDuration.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.displacement.x.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.displacement.y.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.displacement.z.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.pathDistance.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.averageHorizontalSpeed.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.peakHorizontalSpeed.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.rotationDelta.ToString("F4", CultureInfo.InvariantCulture),
|
||
|
|
result.averageAngularSpeed.ToString("F4", CultureInfo.InvariantCulture));
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string FormatVector(Vector3 value)
|
||
|
|
{
|
||
|
|
return $"({value.x.ToString("F4", CultureInfo.InvariantCulture)}, " +
|
||
|
|
$"{value.y.ToString("F4", CultureInfo.InvariantCulture)}, " +
|
||
|
|
$"{value.z.ToString("F4", CultureInfo.InvariantCulture)})";
|
||
|
|
}
|
||
|
|
|
||
|
|
private struct RootMotionCurves
|
||
|
|
{
|
||
|
|
public AnimationCurve positionX;
|
||
|
|
public AnimationCurve positionY;
|
||
|
|
public AnimationCurve positionZ;
|
||
|
|
public AnimationCurve rotationX;
|
||
|
|
public AnimationCurve rotationY;
|
||
|
|
public AnimationCurve rotationZ;
|
||
|
|
public AnimationCurve rotationW;
|
||
|
|
public bool HasPosition => positionX != null || positionY != null || positionZ != null;
|
||
|
|
public bool HasRotation => rotationX != null || rotationY != null || rotationZ != null || rotationW != null;
|
||
|
|
|
||
|
|
public static RootMotionCurves Read(AnimationClip clip)
|
||
|
|
{
|
||
|
|
var bindings = AnimationUtility.GetCurveBindings(clip);
|
||
|
|
var result = new RootMotionCurves
|
||
|
|
{
|
||
|
|
positionX = FindCurve(clip, bindings, "RootT.x", "m_LocalPosition.x", "localPosition.x"),
|
||
|
|
positionY = FindCurve(clip, bindings, "RootT.y", "m_LocalPosition.y", "localPosition.y"),
|
||
|
|
positionZ = FindCurve(clip, bindings, "RootT.z", "m_LocalPosition.z", "localPosition.z"),
|
||
|
|
rotationX = FindCurve(clip, bindings, "RootQ.x", "m_LocalRotation.x", "localRotation.x"),
|
||
|
|
rotationY = FindCurve(clip, bindings, "RootQ.y", "m_LocalRotation.y", "localRotation.y"),
|
||
|
|
rotationZ = FindCurve(clip, bindings, "RootQ.z", "m_LocalRotation.z", "localRotation.z"),
|
||
|
|
rotationW = FindCurve(clip, bindings, "RootQ.w", "m_LocalRotation.w", "localRotation.w")
|
||
|
|
};
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
public string GetStatusText()
|
||
|
|
{
|
||
|
|
if (!HasPosition)
|
||
|
|
{
|
||
|
|
return "未找到 RootT 或根节点 Position 曲线,无法进行区间位移分析。";
|
||
|
|
}
|
||
|
|
|
||
|
|
return HasRotation
|
||
|
|
? "已找到 Root Position 与 Root Rotation 曲线。"
|
||
|
|
: "已找到 Root Position 曲线,但未找到 Root Rotation 曲线。";
|
||
|
|
}
|
||
|
|
|
||
|
|
public Vector3 EvaluatePosition(float time)
|
||
|
|
{
|
||
|
|
return new Vector3(
|
||
|
|
Evaluate(positionX, time),
|
||
|
|
Evaluate(positionY, time),
|
||
|
|
Evaluate(positionZ, time));
|
||
|
|
}
|
||
|
|
|
||
|
|
public Quaternion EvaluateRotation(float time)
|
||
|
|
{
|
||
|
|
Quaternion rotation = new Quaternion(
|
||
|
|
Evaluate(rotationX, time),
|
||
|
|
Evaluate(rotationY, time),
|
||
|
|
Evaluate(rotationZ, time),
|
||
|
|
rotationW == null ? 1f : Evaluate(rotationW, time));
|
||
|
|
|
||
|
|
return rotation.normalized;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static AnimationCurve FindCurve(AnimationClip clip, EditorCurveBinding[] bindings, params string[] propertyNames)
|
||
|
|
{
|
||
|
|
foreach (var binding in bindings)
|
||
|
|
{
|
||
|
|
if (!string.IsNullOrEmpty(binding.path))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool matches = false;
|
||
|
|
foreach (string propertyName in propertyNames)
|
||
|
|
{
|
||
|
|
if (binding.propertyName == propertyName)
|
||
|
|
{
|
||
|
|
matches = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (matches)
|
||
|
|
{
|
||
|
|
return AnimationUtility.GetEditorCurve(clip, binding);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static float Evaluate(AnimationCurve curve, float time)
|
||
|
|
{
|
||
|
|
return curve == null ? 0f : curve.Evaluate(time);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private struct CalibrationResult
|
||
|
|
{
|
||
|
|
public float intervalDuration;
|
||
|
|
public Vector3 startPosition;
|
||
|
|
public Vector3 endPosition;
|
||
|
|
public Vector3 displacement;
|
||
|
|
public Vector3 horizontalDisplacement;
|
||
|
|
public float pathDistance;
|
||
|
|
public Vector3 averageVelocity;
|
||
|
|
public float averageHorizontalSpeed;
|
||
|
|
public float peakHorizontalSpeed;
|
||
|
|
public float rotationDelta;
|
||
|
|
public float averageAngularSpeed;
|
||
|
|
|
||
|
|
public static CalibrationResult Calculate(RootMotionCurves curves, float start, float end, float frameRate)
|
||
|
|
{
|
||
|
|
float duration = Mathf.Max(end - start, MinimumIntervalDuration);
|
||
|
|
int sampleCount = Mathf.Max(2, Mathf.CeilToInt(duration * Mathf.Max(frameRate, MinimumSampleRate)));
|
||
|
|
Vector3 startPosition = curves.EvaluatePosition(start);
|
||
|
|
Vector3 endPosition = curves.EvaluatePosition(end);
|
||
|
|
Vector3 displacement = endPosition - startPosition;
|
||
|
|
|
||
|
|
float pathDistance = 0f;
|
||
|
|
float peakSpeed = 0f;
|
||
|
|
float angleSum = 0f;
|
||
|
|
Vector3 previousPosition = startPosition;
|
||
|
|
Quaternion previousRotation = curves.EvaluateRotation(start);
|
||
|
|
|
||
|
|
for (int i = 1; i <= sampleCount; i++)
|
||
|
|
{
|
||
|
|
float t = Mathf.Lerp(start, end, i / (float)sampleCount);
|
||
|
|
Vector3 currentPosition = curves.EvaluatePosition(t);
|
||
|
|
Quaternion currentRotation = curves.EvaluateRotation(t);
|
||
|
|
float sampleDuration = duration / sampleCount;
|
||
|
|
Vector3 delta = currentPosition - previousPosition;
|
||
|
|
float horizontalDistance = new Vector3(delta.x, 0f, delta.z).magnitude;
|
||
|
|
|
||
|
|
pathDistance += delta.magnitude;
|
||
|
|
peakSpeed = Mathf.Max(peakSpeed, horizontalDistance / sampleDuration);
|
||
|
|
angleSum += Quaternion.Angle(previousRotation, currentRotation);
|
||
|
|
previousPosition = currentPosition;
|
||
|
|
previousRotation = currentRotation;
|
||
|
|
}
|
||
|
|
|
||
|
|
Vector3 horizontalDisplacement = new Vector3(displacement.x, 0f, displacement.z);
|
||
|
|
float rotationDelta = Quaternion.Angle(curves.EvaluateRotation(start), curves.EvaluateRotation(end));
|
||
|
|
|
||
|
|
return new CalibrationResult
|
||
|
|
{
|
||
|
|
intervalDuration = duration,
|
||
|
|
startPosition = startPosition,
|
||
|
|
endPosition = endPosition,
|
||
|
|
displacement = displacement,
|
||
|
|
horizontalDisplacement = horizontalDisplacement,
|
||
|
|
pathDistance = pathDistance,
|
||
|
|
averageVelocity = displacement / duration,
|
||
|
|
averageHorizontalSpeed = horizontalDisplacement.magnitude / duration,
|
||
|
|
peakHorizontalSpeed = peakSpeed,
|
||
|
|
rotationDelta = rotationDelta,
|
||
|
|
averageAngularSpeed = angleSum / duration
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
#endif
|