UI/FacePoser/FacePoseEditor.razor
@using Sandbox;
@using Sandbox.UI;
@attribute [InspectorEditor(typeof(SkinnedModelRenderer))]
@inherits Panel
@namespace Sandbox
@implements IInspectorEditor

<root>
    <div class="body">
            @foreach (var group in Groups)
            {
                <div class="group-header">@group.Key</div>
                @foreach (var row in group)
                {
                    <FacePoseMorphRow Target=@Target
                                      [email protected]
                                      [email protected]
                                      [email protected]
                                      OnMorphChanged=@QueueMorphChange
                                      class=@( IsRowActive( row ) ? "active" : "" )>
                    </FacePoseMorphRow>
                }
            }
    </div>

    <div class="footer">
            <Button class="menu-action primary" Text="Reset" Icon="🧹" onclick=@ClearAll></Button>
            <Button class="menu-action primary" Text="Randomize" Icon="🎲" onclick=@Randomize></Button>
            <Button class="menu-action primary" Text="Presets" Icon="📋" onclick=@OpenPresetsMenu></Button>
    </div>
</root>

@code
{
    public string Title => "😶 Face Poser";

    public SkinnedModelRenderer Target { get; private set; }

    Dictionary<string, float> _pendingMorphs = new();
    TimeSince _lastMorphEdit;

    void QueueMorphChange( string name, float value )
    {
        _pendingMorphs[name] = value;
        _lastMorphEdit = 0;
    }

    void FlushPendingMorphs()
    {
        if ( _pendingMorphs.Count == 0 || Target == null ) return;
        if ( _lastMorphEdit < 0.2f ) return;

        GameManager.ApplyMorphBatch( Target, Sandbox.Json.Serialize( _pendingMorphs ) );
        _pendingMorphs.Clear();
    }

    public override void Tick()
    {
        FlushPendingMorphs();
    }

    public bool TrySetTarget(List<GameObject> selection)
    {
        SkinnedModelRenderer smr = null;
        foreach (var go in selection)
        {
            var found = go.GetComponentInChildren<SkinnedModelRenderer>();
            if (found?.Morphs?.Names?.Length > 0) { smr = found; break; }
        }

        if (smr == Target) return Target != null;

        Target = smr;
        StateHasChanged();
        return Target != null;
    }

    record MorphRow(string NameA, string NameB, string Title);

    IEnumerable<IGrouping<string, MorphRow>> Groups => BuildGroups();

    List<MorphRow> BuildRows()
    {
        if (Target?.Morphs?.Names is not { Length: > 0 } names)
            return new();

        var unprocessed = names.ToList();
        var rows = new List<MorphRow>();

        foreach (var group in unprocessed.GroupBy(x => x[..^1]).ToArray())
        {
            if (group.Count() != 2) continue;

            var lower = group.Select(x => x.ToLower());
            if (!lower.Any(x => x.EndsWith('l')) || !lower.Any(x => x.EndsWith('r')))
                continue;

            unprocessed.RemoveAll(x => group.Contains(x));
            var sorted = group.Order().ToArray();
            rows.Add(new MorphRow(sorted[0], sorted[1], FormatTitle(group.Key)));
        }

        foreach (var name in unprocessed)
            rows.Add(new MorphRow(name, null, FormatTitle(name)));

        return rows;
    }

    IEnumerable<IGrouping<string, MorphRow>> BuildGroups() => BuildRows().GroupBy(r => GetGroup(r.Title)).OrderBy(g => g.Key);

    bool IsRowActive(MorphRow row) => Target.IsValid() && (
        (Target.SceneModel.Morphs.Get(row.NameA) > 0f) ||
        (row.NameB != null && (Target.SceneModel.Morphs.Get(row.NameB) > 0f))
    );

    void ClearAll()
    {
        if (!Target.IsValid() || Target.Morphs?.Names == null) return;

        _pendingMorphs.Clear();

        foreach (var name in Target.Morphs.Names)
            Target.SceneModel.Morphs.Reset(name);

        var zeroed = Target.Morphs.Names.ToDictionary( n => n, _ => 0f );
        GameManager.ApplyFacePosePreset( Target, Sandbox.Json.Serialize( zeroed ) );
        StateHasChanged();
    }

    void Randomize()
    {
        if (!Target.IsValid() || Target.Morphs?.Names is not { Length: > 0 }) return;

        _pendingMorphs.Clear();

        var morphs = new Dictionary<string, float>();

        foreach (var row in BuildRows())
        {
            if (row.NameB != null)
            {
                var strength = Random.Shared.Float(0, 1);
                var side = Random.Shared.Float(-1, 1);
                morphs[row.NameA] = strength * side.Remap(0, -1, 1, 0).Clamp(0, 1);
                morphs[row.NameB] = strength * side.Remap(0, 1, 1, 0).Clamp(0, 1);
            }
            else
            {
                morphs[row.NameA] = Random.Shared.Float(0, 1);
            }
        }

        foreach (var (name, val) in morphs)
            Target.SceneModel.Morphs.Set(name, val);

        GameManager.ApplyFacePosePreset( Target, Sandbox.Json.Serialize( morphs ) );
        StateHasChanged();
    }

    record FacePosePreset( string Name, Dictionary<string, float> Morphs );
    record FacePosePresetList( List<FacePosePreset> Presets );

    string PresetKey => $"faceposer/{System.IO.Path.GetFileNameWithoutExtension( Target?.Model?.Name ?? "unknown" )}";

    FacePosePresetList LoadPresetList() => LocalData.Get<FacePosePresetList>( PresetKey, new FacePosePresetList( new() ) );

    void SaveNewPreset( string name )
    {
        if ( !Target.IsValid() || Target.Morphs?.Names == null ) return;

        var morphs = Target.Morphs.Names.ToDictionary( n => n, n => Target.SceneModel.Morphs.Get( n ) );
        var list = LoadPresetList();
        list.Presets.RemoveAll( p => p.Name == name );
        list.Presets.Add( new FacePosePreset( name, morphs ) );
        LocalData.Set( PresetKey, list );
        StateHasChanged();
    }

    void ApplyPreset( FacePosePreset preset )
    {
        if ( !Target.IsValid() ) return;

        foreach ( var (name, val) in preset.Morphs )
            Target.SceneModel.Morphs.Set( name, val );

        GameManager.ApplyFacePosePreset( Target, Sandbox.Json.Serialize( preset.Morphs ) );
        StateHasChanged();
    }

    void DeletePreset( string name )
    {
        var list = LoadPresetList();
        list.Presets.RemoveAll( p => p.Name == name );
        if ( list.Presets.Count == 0 )
            LocalData.Delete( PresetKey );
        else
            LocalData.Set( PresetKey, list );
        StateHasChanged();
    }

    void OpenPresetsMenu()
    {
        var menu = MenuPanel.Open( this );

        menu.AddOption( "save", "Save New Preset...", () =>
        {
            var popup = new StringQueryPopup
            {
                Title = "Save Preset",
                Prompt = "Enter a name for this preset.",
                Placeholder = "Preset name...",
                ConfirmLabel = "Save",
                OnConfirm = name => SaveNewPreset( name )
            };
            popup.Parent = FindPopupPanel();
        } );

        var list = LoadPresetList();
        if ( list.Presets.Count > 0 )
        {
            menu.AddSpacer();
            foreach ( var preset in list.Presets )
            {
                var captured = preset;
                menu.AddSubmenu( "auto_awesome", captured.Name, sub =>
                {
                    sub.AddOption( "play_arrow", "Load", () => ApplyPreset( captured ) );
                    sub.AddOption( "delete", "Delete", () => DeletePreset( captured.Name ) );
                } );
            }
        }
    }

    static string FormatTitle(string name)
    {
        name = name.Replace("lower", "Lower").Replace("upper", "Upper")
        .Replace("raiser", "Raiser").Replace("inflate", "Inflate")
        .Replace("bulge", "Bulge").Replace("suck", "Suck")
        .Replace("thrust", "Thrust").Replace("sideways", "Sideways")
        .Replace("depressor", "Depressor").Replace("corner", "Corner")
        .Replace("puller", "Puller").Replace("pucker", "Pucker")
        .Replace("wrinkle", "Wrinkle").Replace("jaw", "Jaw")
        .Replace("mouth", "Mouth");
        return name.ToTitleCase();
    }

    static string GetGroup(string title)
    {
        if (title.Contains("brow", StringComparison.OrdinalIgnoreCase)) return "Eyes";
        if (title.Contains("eye", StringComparison.OrdinalIgnoreCase)) return "Eyes";
        if (title.Contains("cheek", StringComparison.OrdinalIgnoreCase)) return "Face";
        if (title.Contains("nose", StringComparison.OrdinalIgnoreCase)) return "Face";
        if (title.Contains("nostril", StringComparison.OrdinalIgnoreCase)) return "Face";
        if (title.Contains("chin", StringComparison.OrdinalIgnoreCase)) return "Face";
        if (title.Contains("jaw", StringComparison.OrdinalIgnoreCase)) return "Mouth";
        if (title.Contains("lip", StringComparison.OrdinalIgnoreCase)) return "Mouth";
        if (title.Contains("mouth", StringComparison.OrdinalIgnoreCase)) return "Mouth";
        return "Misc";
    }

    protected override int BuildHash() => HashCode.Combine(Target);
}