KeyLayer stores non-destructive pose edits per bone/frame and applies them over an animation clip. It manages keyed offsets (translation added, rotation pre-multiplied), easing between keys and falloff outside key ranges, provides CRUD and move operations for keys, evaluates blended offsets for fractional frames, and can apply the layer to a full set of frames.
#nullable enable annotations
using System.Numerics;
using TextToAnimation.Maths;
namespace TextToAnimation.Animation;
using Vector3 = System.Numerics.Vector3; // s&box compat (see Code/TextToAnimation/Assembly.cs)
/// <summary>
/// Non-destructive pose edits on top of a clip's dense frames. A key stores, for one bone at one frame,
/// the parent-local offset from the underlying motion (translation added, rotation pre-multiplied).
/// Between two keys of a bone the offset is eased; before the first and after the last key it fades out
/// over <see cref="FalloffFrames"/>, so posing a bone at one frame never pops the rest of the clip.
/// </summary>
public sealed class KeyLayer
{
readonly Dictionary<string, SortedDictionary<int, XForm>> _keys = new(StringComparer.Ordinal);
/// <summary>Frames over which a key's influence fades in/out beyond the first/last key of a bone.</summary>
public int FalloffFrames { get; set; } = 8;
public bool IsEmpty => _keys.Count == 0;
/// <summary>Bones that have at least one key.</summary>
public IEnumerable<string> KeyedBones => _keys.Keys;
/// <summary>Frames that hold a key on any bone (for the timeline).</summary>
public IEnumerable<int> AllKeyFrames => _keys.Values.SelectMany(k => k.Keys).Distinct().OrderBy(f => f);
public IReadOnlyCollection<int> KeyFrames(string bone)
=> _keys.TryGetValue(bone, out var k) ? k.Keys : (IReadOnlyCollection<int>)Array.Empty<int>();
public bool HasKey(string bone, int frame) => _keys.TryGetValue(bone, out var k) && k.ContainsKey(frame);
public bool TryGetKey(string bone, int frame, out XForm delta)
{
delta = XForm.Identity;
return _keys.TryGetValue(bone, out var k) && k.TryGetValue(frame, out delta);
}
public void SetKey(string bone, int frame, XForm delta)
{
if (frame < 0) throw new ArgumentOutOfRangeException(nameof(frame));
if (!_keys.TryGetValue(bone, out var k)) _keys[bone] = k = new SortedDictionary<int, XForm>();
k[frame] = new XForm(delta.Pos, MathQ.Normalize(delta.Rot));
}
public bool RemoveKey(string bone, int frame)
{
if (!_keys.TryGetValue(bone, out var k) || !k.Remove(frame)) return false;
if (k.Count == 0) _keys.Remove(bone);
return true;
}
/// <summary>Removes every key at <paramref name="frame"/> (all bones). Returns how many were removed.</summary>
public int RemoveKeysAt(int frame)
{
var removed = 0;
foreach (var bone in _keys.Keys.ToList()) if (RemoveKey(bone, frame)) removed++;
return removed;
}
/// <summary>Moves the keys at <paramref name="from"/> to <paramref name="to"/> (for the given bones, or all).</summary>
public int MoveKeys(int from, int to, IEnumerable<string>? bones = null)
{
if (from == to) return 0;
var moved = 0;
foreach (var bone in (bones ?? _keys.Keys).ToList())
{
if (!_keys.TryGetValue(bone, out var k) || !k.TryGetValue(from, out var d)) continue;
k.Remove(from);
k[to] = d;
moved++;
}
return moved;
}
public void ClearBone(string bone) => _keys.Remove(bone);
public void Clear() => _keys.Clear();
public KeyLayer Clone()
{
var c = new KeyLayer { FalloffFrames = FalloffFrames };
foreach (var (bone, keys) in _keys) c._keys[bone] = new SortedDictionary<int, XForm>(keys);
return c;
}
/// <summary>The offset this layer applies to <paramref name="bone"/> at <paramref name="frame"/>.</summary>
public XForm Evaluate(string bone, float frame)
{
if (!_keys.TryGetValue(bone, out var k) || k.Count == 0) return XForm.Identity;
int? prev = null, next = null;
foreach (var f in k.Keys)
{
if (f <= frame) prev = f;
if (f >= frame) { next = f; break; }
}
if (prev is int p && next is int n)
{
if (p == n) return k[p];
var t = Smooth((frame - p) / (n - p));
return Blend(k[p], k[n], t);
}
var falloff = Math.Max(1, FalloffFrames);
if (next is int first) // before the first key
{
var w = 1f - (first - frame) / falloff;
return w <= 0f ? XForm.Identity : Blend(XForm.Identity, k[first], Smooth(w));
}
var last = prev!.Value; // after the last key
var wl = 1f - (frame - last) / falloff;
return wl <= 0f ? XForm.Identity : Blend(XForm.Identity, k[last], Smooth(wl));
}
/// <summary>Returns new frames with every key applied (the input is not modified).</summary>
public List<XForm[]> Apply(IReadOnlyList<XForm[]> frames, Rig.Skeleton skeleton)
{
var result = AnimClip.CopyFrames(frames);
if (IsEmpty) return result;
var bones = _keys.Keys.Select(skeleton.IndexOf).Where(i => i >= 0).ToArray();
for (var f = 0; f < result.Count; f++)
foreach (var b in bones)
{
var d = Evaluate(skeleton[b].Name, f);
ref var local = ref result[f][b];
local = ApplyDelta(local, d);
}
return result;
}
/// <summary>Applies an offset to a parent-local transform.</summary>
public static XForm ApplyDelta(XForm local, XForm delta)
=> new(local.Pos + delta.Pos, MathQ.Normalize(delta.Rot * local.Rot));
/// <summary>The offset that turns <paramref name="baseLocal"/> into <paramref name="targetLocal"/>.</summary>
public static XForm DeltaBetween(XForm baseLocal, XForm targetLocal)
=> new(targetLocal.Pos - baseLocal.Pos, MathQ.Normalize(targetLocal.Rot * Quaternion.Conjugate(baseLocal.Rot)));
static XForm Blend(XForm a, XForm b, float t)
=> new(Vector3.Lerp(a.Pos, b.Pos, t), MathQ.Normalize(Quaternion.Slerp(a.Rot, b.Rot, t)));
static float Smooth(float t)
{
t = Math.Clamp(t, 0f, 1f);
return t * t * (3f - 2f * t);
}
// ---- serialization helpers (used by the workspace store) ----
internal IEnumerable<(string Bone, int Frame, XForm Delta)> Enumerate()
{
foreach (var (bone, keys) in _keys)
foreach (var (frame, d) in keys)
yield return (bone, frame, d);
}
}