Code/BetterIk/Maths/PlantWindow.cs

A small utility that tracks a trailing minimum of float values over a fixed time window. It stores time/value samples in a ring buffer, accepts samples via Push(now, value), and returns the minimum value seen within WindowSeconds ending at the given time.

namespace BetterIk.Maths;

/// <summary>
/// Trailing-minimum tracker over a fixed time window. Feed it one plant-point height per
/// frame with the current time; <see cref="Push"/> returns the lowest value seen within the
/// last <see cref="WindowSeconds"/>. Plant-to-ground foot placement uses it to find the
/// "planted" level of a cyclic animation (the lowest the foot reached recently), so that only
/// that constant is removed and authored lifts above it are preserved.
///
/// This is the one stateful helper in the Maths layer; the solvers themselves stay pure. It is
/// engine-agnostic (floats and a time value only), so it is unit-testable without an editor,
/// like the rest of the core. Samples are assumed pushed in non-decreasing time order (the
/// engine wall-clock during a frame loop); the newest <see cref="Capacity"/> samples are
/// retained, so at very high frame rates a window longer than the buffer can hold is measured
/// over as many recent samples as fit rather than the full duration.
/// </summary>
public sealed class PlantWindow
{
    private readonly float[] _time;
    private readonly float[] _value;
    private int _head;   // index of the next write
    private int _count;

    /// <summary>Length of the trailing window in the same time unit passed to <see cref="Push"/>.</summary>
    public float WindowSeconds { get; set; }

    public int Capacity => _time.Length;
    public bool HasSamples => _count > 0;

    public PlantWindow(float windowSeconds, int capacity = 512)
    {
        if (capacity < 1)
            capacity = 1;
        WindowSeconds = windowSeconds;
        _time = new float[capacity];
        _value = new float[capacity];
    }

    /// <summary>Discard all samples (e.g. on a hard clip cut) so the window rebuilds from scratch.</summary>
    public void Reset()
    {
        _head = 0;
        _count = 0;
    }

    /// <summary>
    /// Record a sample and return the trailing minimum over the window ending at
    /// <paramref name="now"/>. With an empty history this is just <paramref name="value"/>.
    /// </summary>
    public float Push(float now, float value)
    {
        _time[_head] = now;
        _value[_head] = value;
        _head = (_head + 1) % _time.Length;
        if (_count < _time.Length)
            _count++;

        return MinSince(now);
    }

    /// <summary>Trailing minimum over the window ending at <paramref name="now"/> without adding a sample.</summary>
    public float MinSince(float now)
    {
        if (_count == 0)
            return 0f;

        float window = WindowSeconds;
        if (window < 0f)
            window = 0f;
        float cutoff = now - window;

        float min = float.MaxValue;
        // Walk from the most recent sample backward. Time is non-decreasing in push order, so
        // the first sample older than the cutoff means every earlier one is out of window too.
        for (int i = 0; i < _count; i++)
        {
            int idx = _head - 1 - i;
            if (idx < 0)
                idx += _time.Length;
            if (_time[idx] < cutoff)
                break;
            if (_value[idx] < min)
                min = _value[idx];
        }
        // At least the newest sample is always within [cutoff, now] when window >= 0.
        return min == float.MaxValue ? _value[(_head - 1 + _time.Length) % _time.Length] : min;
    }
}