Code/Demos/Compass/CompassMath.cs
using System;
using System.Collections.Generic;

namespace Sandbox.Compass;

// Pure compass angle math. No s&box dependencies so it is unit-testable.
public static class CompassMath
{
    // Folds any degree value into [0, 360).
    public static float Normalize360(float deg)
    {
        deg %= 360f;
        if (deg < 0f) deg += 360f;
        return deg;
    }

    // Shortest signed angle from heading to mark, in (-180, 180]; makes the 360-to-0 wrap seamless.
    public static float SignedDelta(float headingDeg, float markDeg)
    {
        float d = Normalize360(markDeg - headingDeg);
        if (d > 180f) d -= 360f;
        return d;
    }

    // 8-point label rounded to the nearest 45 deg; 0 = N, increasing clockwise.
    public static string CardinalLabel(float deg)
    {
        int idx = ((int)MathF.Round(Normalize360(deg) / 45f)) % 8;
        return idx switch
        {
            0 => "N", 1 => "NE", 2 => "E", 3 => "SE",
            4 => "S", 5 => "SW", 6 => "W", 7 => "NW",
            _ => "",
        };
    }

    // One strip mark: angle, XNorm (0 at center caret, +/-1 at window edges), cardinal at 45 deg steps.
    public readonly record struct Mark(float AngleDeg, float XNorm, bool IsCardinal);

    // Marks inside the visible arc [heading - windowDeg/2, heading + windowDeg/2].
    public static List<Mark> VisibleMarks(float headingDeg, float windowDeg, float stepDeg)
    {
        var marks = new List<Mark>();
        float half = windowDeg * 0.5f;
        int count = (int)MathF.Round(360f / stepDeg);
        for (int i = 0; i < count; i++)
        {
            float a = Normalize360(i * stepDeg);
            float d = SignedDelta(headingDeg, a);
            if (MathF.Abs(d) <= half)
            {
                int deg = (int)MathF.Round(a);
                bool cardinal = deg % 45 == 0;
                marks.Add(new Mark(a, d / half, cardinal));
            }
        }
        return marks;
    }
}