Editor/WeaponImporter/Core/Geometry/ShapeProfile.cs
#nullable enable annotations

using System.Numerics;

namespace WeaponImporter.Core.Geometry;

using Vector3 = System.Numerics.Vector3;

/// <summary>
/// Silhouette of a weapon sliced along its length (+X): per slice the lowest and highest
/// surface and the left/right extent. Pistol grips, magazines, stocks and handguards all show
/// up as characteristic shapes in these profiles.
/// </summary>
public sealed class ShapeProfile
{
    public float MinX { get; }
    public float BinWidth { get; }
    public int Count { get; }

    public float[] Bottom { get; }
    public float[] Top { get; }
    public float[] Left { get; }   // +Y
    public float[] Right { get; }  // -Y
    public int[] Samples { get; }

    public float MaxX => MinX + BinWidth * Count;

    private ShapeProfile(float minX, float width, int count)
    {
        MinX = minX;
        BinWidth = width;
        Count = count;
        Bottom = Enumerable.Repeat(float.MaxValue, count).ToArray();
        Top = Enumerable.Repeat(float.MinValue, count).ToArray();
        Left = Enumerable.Repeat(float.MinValue, count).ToArray();
        Right = Enumerable.Repeat(float.MaxValue, count).ToArray();
        Samples = new int[count];
    }

    public bool Has(int bin) => bin >= 0 && bin < Count && Samples[bin] > 0;
    public float X(int bin) => MinX + (bin + 0.5f) * BinWidth;
    public int Bin(float x) => Math.Clamp((int)((x - MinX) / BinWidth), 0, Count - 1);
    public float Height(int bin) => Has(bin) ? Top[bin] - Bottom[bin] : 0f;
    public float Width(int bin) => Has(bin) ? Left[bin] - Right[bin] : 0f;

    /// <summary>Builds the profile from the given triangles (all when null).</summary>
    public static ShapeProfile Build(TriMesh mesh, IEnumerable<int>? triangles = null, float binWidth = 0.25f)
    {
        var tris = triangles?.ToArray() ?? Enumerable.Range(0, mesh.TriangleCount).ToArray();
        var bounds = Bounds.Empty;
        foreach (var t in tris)
        {
            var (a, b, c) = mesh.Triangle(t);
            bounds = bounds.Encapsulate(a).Encapsulate(b).Encapsulate(c);
        }
        if (bounds.IsEmpty)
            return new ShapeProfile(0f, binWidth, 1);

        var length = MathF.Max(bounds.Size.X, binWidth);
        // Keep the bin count bounded for very long or very finely tessellated props.
        binWidth = MathF.Max(binWidth, length / 400f);
        var count = Math.Max(1, (int)MathF.Ceiling(length / binWidth) + 1);
        var profile = new ShapeProfile(bounds.Min.X, binWidth, count);

        foreach (var t in tris)
        {
            var (a, b, c) = mesh.Triangle(t);
            // Sample the triangle densely enough that large faces land in every bin they cross.
            var span = MathF.Max(MathF.Max(Vector3.Distance(a, b), Vector3.Distance(b, c)), Vector3.Distance(c, a));
            var steps = Math.Clamp((int)MathF.Ceiling(span / (binWidth * 0.75f)), 1, 64);
            for (var i = 0; i <= steps; i++)
            {
                for (var j = 0; j <= steps - i; j++)
                {
                    var u = i / (float)steps;
                    var v = j / (float)steps;
                    profile.Add(a + (b - a) * u + (c - a) * v);
                }
            }
        }
        return profile;
    }

    private void Add(Vector3 p)
    {
        var bin = Bin(p.X);
        Samples[bin]++;
        Bottom[bin] = MathF.Min(Bottom[bin], p.Z);
        Top[bin] = MathF.Max(Top[bin], p.Z);
        Left[bin] = MathF.Max(Left[bin], p.Y);
        Right[bin] = MathF.Min(Right[bin], p.Y);
    }

    /// <summary>Median of a per-bin value over occupied bins in [from, to].</summary>
    public float Median(Func<int, float> value, int from = 0, int to = int.MaxValue)
    {
        var values = new List<float>();
        for (var i = Math.Max(0, from); i <= Math.Min(Count - 1, to); i++)
            if (Has(i))
                values.Add(value(i));
        if (values.Count == 0)
            return 0f;
        values.Sort();
        return values[values.Count / 2];
    }

    /// <summary>Percentile (0..1) of a per-bin value over occupied bins in [from, to].</summary>
    public float Percentile(Func<int, float> value, float q, int from = 0, int to = int.MaxValue)
    {
        var values = new List<float>();
        for (var i = Math.Max(0, from); i <= Math.Min(Count - 1, to); i++)
            if (Has(i))
                values.Add(value(i));
        if (values.Count == 0)
            return 0f;
        values.Sort();
        return values[Math.Clamp((int)(q * (values.Count - 1)), 0, values.Count - 1)];
    }

    /// <summary>Contiguous runs of occupied bins where <paramref name="predicate"/> holds.</summary>
    public List<(int Start, int End)> Runs(Func<int, bool> predicate, int minLength = 1)
    {
        var runs = new List<(int, int)>();
        var start = -1;
        for (var i = 0; i <= Count; i++)
        {
            var on = i < Count && Has(i) && predicate(i);
            if (on && start < 0)
                start = i;
            else if (!on && start >= 0)
            {
                if (i - start >= minLength)
                    runs.Add((start, i - 1));
                start = -1;
            }
        }
        return runs;
    }
}