2460 results

using Editor;
using Sandbox;
using Sandbox.Helpers;
using System.Collections.Generic;
using System.Linq;

namespace SFXR.Editor;

[CustomEditor( typeof( List<SFXRSequencer.Note> ) )]
public class SFXRNotesListControlWidget : ControlWidget
{
    private SerializedCollection Collection;

    private Layout Content;

    private Button addButton;

    public override bool SupportsMultiEdit => false;

    SFXRSequencer Sequencer;

    public SFXRNotesListControlWidget( SerializedProperty property )
        : base( property )
    {
        SetSizeMode( SizeMode.Ignore, SizeMode.Ignore );

        base.Layout = Layout.Column();
        base.Layout.Spacing = 2f;
        if ( property.TryGetAsObject( out var obj ) && obj is SerializedCollection collection )
        {
            if ( property.Parent.Targets.First() is SFXRSequencer sequencer )
            {
                Sequencer = sequencer;
            }

            Collection = collection;
            Collection.OnEntryAdded = Rebuild;
            Collection.OnEntryRemoved = Rebuild;
            Content = Layout.Column();
            base.Layout.Add( Content );
            Layout layout = base.Layout.AddRow();
            layout.Margin = 8;
            layout.AddStretchCell();
            addButton = layout.Add( new Button( "Add Note" )
            {
                ToolTip = "Add new note",
            } );
            addButton.MinimumWidth = 200;
            addButton.Clicked = () => AddEntry();
            layout.AddStretchCell();
            Rebuild();
        }
    }

    public void Rebuild()
    {
        Content.Clear( deleteWidgets: true );
        Content.Margin = 0f;
        Layout layout = Layout.Column();
        layout.Spacing = 2f;
        int num = 0;
        int count = Collection.Count();
        for ( int i = 0; i < count; i++ )
        {
            var item = Collection.ElementAt( i );
            int index = num;
            var itemLayout = Layout.Row();
            itemLayout.Spacing = 4f;
            // try to get object
            if ( item.TryGetAsObject( out var obj ) )
            {
                var thing = new SFXRNoteSheet( obj );
                itemLayout.Add( thing );
            }
            else
            {
                var thing = ControlWidget.Create( item );
                thing.MinimumHeight = 100;
                itemLayout.Add( thing );
            }

            var buttonLayout = Layout.Column();
            if ( i > 0 )
            {
                buttonLayout.Add( new IconButton( "arrow_upward", delegate
                {
                    MoveUp( index );
                } )
                {
                    Background = Color.Transparent,
                    FixedWidth = ControlWidget.ControlRowHeight,
                    FixedHeight = ControlWidget.ControlRowHeight,
                    ToolTip = "Move note up"
                } );
            }
            else
            {
                buttonLayout.AddSpacingCell( 25 );
            }

            buttonLayout.Add( new IconButton( "delete", delegate
            {
                RemoveEntry( index );
            } )
            {
                Background = Color.Red,
                FixedWidth = ControlWidget.ControlRowHeight,
                FixedHeight = ControlWidget.ControlRowHeight,
                ToolTip = "Delete note"
            } );

            if ( i < count - 1 )
            {
                buttonLayout.Add( new IconButton( "arrow_downward", delegate
                {
                    MoveDown( index );
                } )
                {
                    Background = Color.Transparent,
                    FixedWidth = ControlWidget.ControlRowHeight,
                    FixedHeight = ControlWidget.ControlRowHeight,
                    ToolTip = "Move note down"
                } );
            }
            else
            {
                buttonLayout.AddSpacingCell( 25 );
            }

            itemLayout.Add( buttonLayout );
            layout.Add( itemLayout );
            num++;
        }

        MinimumHeight = 50 + (num * 105);

        Content.Add( layout );
        Content.Margin = ((num > 0) ? 3 : 0);
    }

    private void AddEntry()
    {
        Collection.Add( new SFXRSequencer.Note() );
    }

    private void RemoveEntry( int index )
    {
        Collection.RemoveAt( index );
    }

    private void MoveUp( int index )
    {
        // Move the index up in Sequencer.Notes list
        if ( index > 0 )
        {
            var note = Sequencer.Notes[index];
            Sequencer.Notes.RemoveAt( index );
            Sequencer.Notes.Insert( index - 1, note );
        }

        Rebuild();
    }

    private void MoveDown( int index )
    {
        // Move the index down in Sequencer.Notes list
        if ( index < Sequencer.Notes.Count - 1 )
        {
            var note = Sequencer.Notes[index];
            Sequencer.Notes.RemoveAt( index );
            Sequencer.Notes.Insert( index + 1, note );
        }

        Rebuild();
    }

    protected override void OnPaint()
    {

    }

    public void AddEffectDialog( Button source )
    {
        var s = new SFXREffectTypeSelector( this );
        s.OnSelect += ( t ) => AddEffect( t );
        s.OpenAt( source.ScreenRect.BottomLeft, animateOffset: new Vector2( 0, -4 ) );
        s.FixedWidth = source.Width;
    }

    void AddEffect( TypeDescription type )
    {
        if ( !type.TargetType.IsAssignableTo( typeof( SFXREffect ) ) )
        {
            Log.Error( $"Type {type.TargetType} is not assignable to {typeof( SFXREffect )}" );
            return;
        }

        SFXREffect effect = type.Create<SFXREffect>();
        Collection.Add( effect );

        Log.Info( effect );
    }


}
using Editor;

public static class MyEditorMenu
{
	[Menu( "Editor", "CrosshairBuilder/My Menu Option" )]
	public static void OpenMyMenu()
	{
		EditorUtility.DisplayDialog( "It worked!", "This is being called from your library's editor code!" );
	}
}
global using System;
global using System.Linq;
global using System.Collections.Generic;
global using Editor;
global using Sandbox;
global using PathTool;
global using Application = Editor.Application;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.IO;
using Editor;
using Sandbox;
using System.Text;

class SpectogramWidget : Widget 
{
    private short[] samples;
    private int sampleRate;
    private List<int> splitPoints = new List<int>();
    private int? dragPoint = null;
    private Label loadingLabel;
    private Label dropLabel;
    private bool isLoading = true;
    public SoundFile CurrentSound { get; private set; }

    public SpectogramWidget(SoundFile soundFile) : base(null)
    {
        MinimumSize = 100;
        MouseTracking = true;
        AcceptDrops = true;

        loadingLabel = new Label(this);
        loadingLabel.Text = "Loading audio data...";
        loadingLabel.Visible = false;
        
        dropLabel = new Label(this);
        dropLabel.Text = "Drop a sound file here";
        dropLabel.SetStyles("font-size: 18px; color: #aaa; text-align: center;");
        
        if (soundFile != null)
        {
            LoadSound(soundFile);
        }
    }

    public async void LoadSound(SoundFile soundFile)
    {
        CurrentSound = soundFile;
        isLoading = true;
        samples = null;
        splitPoints.Clear();
        
        loadingLabel.Visible = true;
        dropLabel.Visible = false;

        await LoadAudioDataAsync(soundFile);
    }

    private async Task LoadAudioDataAsync(SoundFile soundFile)
    {
        try 
        {
            await soundFile.LoadAsync();
            samples = await soundFile.GetSamplesAsync();
            
            if (samples == null)
            {
                loadingLabel.Text = "Failed to load audio data";
                return;
            }

            sampleRate = soundFile.Rate;
            splitPoints.Add(0);
            splitPoints.Add(samples.Length - 1);

            loadingLabel.Visible = false;
            isLoading = false;

            Update();
        }
        catch (Exception ex)
        {
            loadingLabel.Text = $"Error loading audio: {ex.Message}";
        }
    }

    protected override void DoLayout()
    {
        base.DoLayout();

        if (loadingLabel != null)
        {
            loadingLabel.Position = new Vector2(10, Height / 2 - 10);
            loadingLabel.Size = new Vector2(Width - 20, 20);
        }

        if (dropLabel != null)
        {
            dropLabel.Position = new Vector2(10, Height / 2 - 10);
            dropLabel.Size = new Vector2(Width - 20, 20);
        }
    }

    public override void OnDragDrop(DragEvent e)
    {
        base.OnDragDrop(e);

        if (!e.Data.HasFileOrFolder) return;

        var asset = AssetSystem.FindByPath(e.Data.FileOrFolder);
        if (asset?.AssetType != AssetType.SoundFile) return;

        var soundFile = SoundFile.Load(asset.Path);
        if (soundFile != null)
        {
            LoadSound(soundFile);
        }
    }

    public override void OnDragHover(DragEvent e)
    {
        base.OnDragHover(e);

        if (!e.Data.HasFileOrFolder) return;

        var asset = AssetSystem.FindByPath(e.Data.FileOrFolder);
        if (asset?.AssetType != AssetType.SoundFile) return;

        e.Action = DropAction.Link;
    }

    protected override void OnMouseClick(MouseEvent e)
    {
        if (isLoading) return;
        
        base.OnMouseClick(e);

        if (e.Button == MouseButtons.Left)
        {
            var samplePos = (int)(e.LocalPosition.x / Width * samples.Length);
            var nearPoint = splitPoints.FirstOrDefault(p => Math.Abs(p - samplePos) < (samples.Length / Width * 5));
            
            if (nearPoint != default)
            {
                dragPoint = splitPoints.IndexOf(nearPoint);
            }
            else
            {
                splitPoints.Add(samplePos);
                splitPoints.Sort();
                Update();
            }
        }
    }

    protected override void OnMouseMove(MouseEvent e)
    {
        if (isLoading) return;
        
        base.OnMouseMove(e);

        if (dragPoint.HasValue)
        {
            var samplePos = (int)(e.LocalPosition.x / Width * samples.Length);
            splitPoints[dragPoint.Value] = samplePos;
            splitPoints.Sort();
            Update();
        }
    }

    protected override void OnMouseReleased(MouseEvent e)
    {
        if (isLoading) return;
        
        base.OnMouseReleased(e);
        dragPoint = null;
    }

    protected override void OnPaint()
    {
        base.OnPaint();

        if (isLoading || samples == null)
        {
            return;
        }

        Paint.ClearPen();
        Paint.SetBrush(Theme.Grey.WithAlpha(0.1f));
        Paint.DrawRect(LocalRect);

        Paint.SetPen(Theme.Blue);
        var samplesPerPixel = samples.Length / Width;
        for (int x = 0; x < Width; x++)
        {
            var startSample = (int)(x * samplesPerPixel);
            var endSample = Math.Min(startSample + samplesPerPixel, samples.Length);
            
            var max = short.MinValue;
            var min = short.MaxValue;
            
            for (int i = startSample; i < endSample; i++)
            {
                max = Math.Max(max, samples[i]);
                min = Math.Min(min, samples[i]);
            }

            var y1 = Height / 2 + (min / (float)short.MaxValue * Height / 2);
            var y2 = Height / 2 + (max / (float)short.MaxValue * Height / 2);
            
            Paint.DrawLine(new Vector2(x, y1), new Vector2(x, y2));
        }

        Paint.SetPen(Theme.Red);
        foreach (var point in splitPoints)
        {
            var x = point / (float)samples.Length * Width;
            Paint.DrawLine(new Vector2(x, 0), new Vector2(x, Height));
        }
    }

    public List<int> GetSplitPoints()
    {
        return new List<int>(splitPoints);
    }

    public void SplitCurrentSound(Action<SoundFile> onSoundCreated)
    {
        if (CurrentSound == null || samples == null) return;

        var splitPoints = GetSplitPoints();
        if (splitPoints.Count < 2) return;

        try
        {
            var baseFileName = Path.GetFileNameWithoutExtension(CurrentSound.ResourcePath);
            var outputDir = Path.Combine(
                Project.Current.GetAssetsPath(),
                "generated",
                $"{baseFileName}_splits"
            );
            Directory.CreateDirectory(outputDir);

            for (int i = 0; i < splitPoints.Count - 1; i++)
            {
                var start = splitPoints[i];
                var end = splitPoints[i + 1];
                var length = end - start;

                var segmentSamples = new short[length];
                Array.Copy(samples, start, segmentSamples, 0, length);

                var wavPath = Path.Combine(outputDir, $"{baseFileName}_part_{i + 1}.wav");

                using (var writer = new BinaryWriter(File.Create(wavPath)))
                {
                    writer.Write(Encoding.ASCII.GetBytes("RIFF"));
                    writer.Write(36 + (segmentSamples.Length * 2));
                    writer.Write(Encoding.ASCII.GetBytes("WAVE"));

                    writer.Write(Encoding.ASCII.GetBytes("fmt "));
                    writer.Write(16);
                    writer.Write((short)1);
                    writer.Write((short)CurrentSound.Channels);
                    writer.Write(CurrentSound.Rate);
                    writer.Write(CurrentSound.Rate * CurrentSound.Channels * 2);
                    writer.Write((short)(CurrentSound.Channels * 2));
                    writer.Write((short)16);

                    writer.Write(Encoding.ASCII.GetBytes("data"));
                    writer.Write(segmentSamples.Length * 2);

                    foreach (var sample in segmentSamples)
                    {
                        writer.Write(sample);
                    }
                }

                var asset = AssetSystem.RegisterFile(wavPath);
                if (asset != null)
                {
                    var soundFile = SoundFile.Load(asset.RelativePath);
                    if (soundFile != null)
                    {
                        onSoundCreated?.Invoke(soundFile);
                    }
                }
            }
        }
        catch (Exception ex)
        {
            Log.Error($"Error splitting sound: {ex.Message}");
        }
    }
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
global using Dreams.UltimateLightManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Sandbox;

namespace Sandbox.AssetBrowserAddon;

/// <summary>
/// Dialog that captures the settings for a new custom Asset Browser location.
/// </summary>
public sealed class LocationEditor : Dialog
{
    private readonly Action<CustomLocationDefinition> _onConfirm;
    private readonly CustomLocationDefinition _initialDefinition;
    private readonly LineEdit _nameInput;
    private readonly LineEdit _iconInput;
    private readonly LineEdit _includeInput;
    private readonly LineEdit _excludeInput;
    private readonly AssetTypeSelector _assetTypeSelector;
    private readonly ToggleSwitch _projectOnlyToggle;

    public LocationEditor(Action<CustomLocationDefinition> onConfirm, CustomLocationDefinition initialDefinition = null)
    {
        _onConfirm = onConfirm;
        _initialDefinition = initialDefinition;

        Window.Title = initialDefinition is null ? "Add Bookmark" : "Edit Bookmark";
        Window.Size = new Vector2(500, 750);

        Layout = Layout.Column();
        Layout.Margin = 16f;
        Layout.Spacing = 12f;

        _nameInput = AddTextRow("Title", "My Bookmark");
        _iconInput = AddIconRow("Icon", "bookmark");

        Layout.Add( new Label( this ) { Text = "Asset Types" } );
        _assetTypeSelector = Layout.Add( new AssetTypeSelector( this, StyleInput ) );

        _projectOnlyToggle = Layout.Add( new ToggleSwitch( "Only include assets from this project", this ) );
        _projectOnlyToggle.MinimumHeight = Theme.RowHeight;
        _projectOnlyToggle.Value = true;

        _includeInput = AddTextArea("Include Folders", "Separate folders with ;");
        _excludeInput = AddTextArea("Exclude Folders", "Separate folders with ;");
        if ( _initialDefinition is not null )
        {
            _nameInput.Text = _initialDefinition.Name;
            _iconInput.Text = _initialDefinition.Icon;
            _assetTypeSelector.SetSelected( _initialDefinition.AssetTypes );
            _includeInput.Text = string.Join( ';', _initialDefinition.IncludeFolders ?? new List<string>() );
            _excludeInput.Text = string.Join( ';', _initialDefinition.ExcludeFolders ?? new List<string>() );
            _projectOnlyToggle.Value = _initialDefinition.ProjectAssetsOnly;

        }

        var buttonRow = Layout.AddRow();
        buttonRow.Spacing = 8f;
        buttonRow.AddStretchCell();
        buttonRow.Add( new Button( "Cancel", this ) { Clicked = Close } );
        var buttonLabel = _initialDefinition is null ? "Add Bookmark" : "Save Bookmark";
        var buttonIcon = _initialDefinition is null ? "add" : "save";
        buttonRow.Add( new Button.Primary( buttonLabel, buttonIcon, this ) { Clicked = Submit } );
    }

    private LineEdit AddTextRow(string label, string placeholder)
    {
        var row = Layout.AddRow();
        row.Spacing = 8f;
        row.Add( new Label( this ) { Text = label, FixedWidth = 130 } );

        var input = row.Add( new LineEdit( this ) );
        input.PlaceholderText = placeholder;
        StyleInput( input );
        return input;
    }

    private LineEdit AddIconRow(string label, string placeholder)
    {
        var row = Layout.AddRow();
        row.Spacing = 8f;
        row.Add( new Label( this ) { Text = label, FixedWidth = 130 } );

        var input = row.Add( new LineEdit( this ) );
        input.PlaceholderText = placeholder;
        StyleInput( input );

        var button = row.Add( new IconButton( "search", () => ShowIconPicker( input ), this ) );
        button.ToolTip = "Browse material icons";
        button.MinimumWidth = Theme.RowHeight;

        return input;
    }

    private LineEdit AddTextArea(string label, string placeholder)
    {
        var column = Layout.Add( Layout.Column() );
        column.Spacing = 4f;
        column.Add( new Label( this ) { Text = label } );

        var input = column.Add( new LineEdit( this ) );
        input.PlaceholderText = placeholder;
        StyleInput( input );
        return input;
    }

    private void Submit()
    {
        var name = _nameInput.Text?.Trim();
        if ( string.IsNullOrWhiteSpace( name ) )
        {
            EditorUtility.DisplayDialog( "Missing Title", "Please enter a title for the bookmark." );
            return;
        }

        var icon = string.IsNullOrWhiteSpace( _iconInput.Text ) ? "extension" : _iconInput.Text.Trim();

        var definition = _initialDefinition is null
            ? new CustomLocationDefinition()
            : new CustomLocationDefinition { Id = _initialDefinition.Id };

        definition.Name = name;
        definition.Icon = icon;
        definition.AssetTypes = _assetTypeSelector.SelectedTags.Select( NormalizeExtension ).Where( x => !string.IsNullOrWhiteSpace( x ) ).ToList();
        definition.IncludeFolders = SplitToList( _includeInput.Text );
        definition.ExcludeFolders = SplitToList( _excludeInput.Text );
        definition.ProjectAssetsOnly = _projectOnlyToggle.Value;

        _onConfirm?.Invoke( definition );
        Close();
    }

    private void ShowIconPicker( LineEdit target )
    {
        var pickerType = AppDomain.CurrentDomain.GetAssemblies()
            .Select( asm => asm.GetType( "Editor.IconPickerWidget", false ) )
            .FirstOrDefault( t => t is not null );

        var openPopup = pickerType?.GetMethod( "OpenPopup", BindingFlags.Public | BindingFlags.Static );
        if ( openPopup is null )
        {
            EditorUtility.DisplayDialog( "Icon Picker", "Unable to locate the icon picker widget." );
            return;
        }

        openPopup.Invoke( null, new object[]
        {
            this,
            target.Text ?? string.Empty,
            (Action<string>)(value => target.Text = value)
        } );
    }

    private static List<string> SplitToList(string raw)
    {
        if ( string.IsNullOrWhiteSpace( raw ) )
            return new List<string>();

        return raw
            .Split( new[] { ';', ',', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries )
            .Select( part => part.Trim() )
            .Where( part => part.Length > 0 )
            .ToList();
    }

    private static string NormalizeExtension( string value )
    {
        if ( string.IsNullOrWhiteSpace( value ) )
            return string.Empty;

        return value.Trim().TrimStart( '.' ).ToLowerInvariant();
    }

    private static void StyleInput( LineEdit input )
    {
        var background = Theme.ControlBackground.Darken( 0.25f ).Hex;
        var border = Theme.Border.Hex;
        input.SetStyles( $"background-color: {background}; border-color: {border};" );
    }
}
using System;
using System.Linq;
using Sandbox;

namespace Editor.TerrainConvert;

/// <summary>
/// Which source channel to read the height value from.
/// </summary>
public enum HeightChannel
{
	/// <summary> Red channel only. Standard for grayscale heightmaps. </summary>
	Red,
	Green,
	Blue,
	Alpha,
	/// <summary> Rec.709 weighted luminance of RGB. </summary>
	Luminance,
	/// <summary> Average of the RGB channels. </summary>
	Average,
	/// <summary> The largest of the RGB channels. </summary>
	Max,
}

/// <summary>
/// Bit depth of the output .raw file. s&box terrain stores heights as 16-bit,
/// so 16-bit is recommended for the best precision.
/// </summary>
public enum HeightBitDepth
{
	/// <summary> 16-bit per height sample (recommended). 2 bytes per pixel. </summary>
	Bit16,
	/// <summary> 8-bit per height sample. 1 byte per pixel. </summary>
	Bit8,
}

/// <summary>
/// How the source values are remapped into the 0..1 height range before quantizing.
/// </summary>
public enum HeightNormalize
{
	/// <summary> Use values as-is, clamped to 0..1. </summary>
	Clamp,
	/// <summary> Stretch the min..max range of the image to fill 0..1 (good for HDR/EXR). </summary>
	MinMaxStretch,
}

/// <summary>
/// Byte order of multi-byte (16-bit) samples in the output file.
/// </summary>
public enum HeightByteOrder
{
	/// <summary> Little-endian. What the s&box terrain importer reads by default ("Windows"). </summary>
	LittleEndian,
	/// <summary> Big-endian ("Mac"). </summary>
	BigEndian,
}

/// <summary>
/// Settings for converting an image into a terrain heightmap .raw file.
/// </summary>
public class HeightmapConvertSettings
{
	/// <summary>
	/// Which channel of the source image holds the height. Most heightmaps are
	/// grayscale, where every channel is identical - <see cref="HeightChannel.Red"/> works for those.
	/// </summary>
	[Property]
	public HeightChannel Channel { get; set; } = HeightChannel.Red;

	/// <summary>
	/// 16-bit gives the smoothest terrain and matches what s&box stores internally.
	/// </summary>
	[Property]
	public HeightBitDepth BitDepth { get; set; } = HeightBitDepth.Bit16;

	/// <summary>
	/// Output heightmaps are always square. If 0, the smaller of the source
	/// dimensions is used. The image is resampled to this resolution.
	/// </summary>
	[Property, Range( 0, 8192 )]
	public int Resolution { get; set; } = 0;

	/// <summary>
	/// Round the output resolution down to the nearest power of two (e.g 1024, 2048).
	/// The terrain system expects power-of-two heightmaps, so leave this on.
	/// </summary>
	[Property]
	public bool PowerOfTwo { get; set; } = true;

	/// <summary>
	/// How source values are mapped to the height range. Use <see cref="HeightNormalize.MinMaxStretch"/>
	/// for HDR images that don't already fill 0..1.
	/// </summary>
	[Property]
	public HeightNormalize Normalize { get; set; } = HeightNormalize.Clamp;

	/// <summary>
	/// Flip the image vertically. Image and terrain coordinate origins often differ;
	/// toggle this if your terrain comes out mirrored north/south.
	/// </summary>
	[Property]
	public bool FlipVertical { get; set; } = false;

	/// <summary>
	/// Invert the heights (high becomes low). Useful for depth/inverted maps.
	/// </summary>
	[Property]
	public bool Invert { get; set; } = false;

	/// <summary>
	/// Byte order of 16-bit samples. The s&box importer reads little-endian by default.
	/// </summary>
	[Property, ShowIf( nameof( BitDepth ), HeightBitDepth.Bit16 )]
	public HeightByteOrder ByteOrder { get; set; } = HeightByteOrder.LittleEndian;
}

/// <summary>
/// Converts loaded images into raw single-channel heightmaps compatible with the
/// s&box terrain importer (square, 8 or 16 bit, raw samples with no header).
/// </summary>
public static class HeightmapConverter
{
	/// <summary>
	/// Convert a bitmap into a raw heightmap byte buffer.
	/// </summary>
	/// <param name="bitmap">Source image.</param>
	/// <param name="settings">Conversion settings.</param>
	/// <param name="resolution">The square resolution of the produced heightmap.</param>
	/// <returns>Raw heightmap bytes ready to write to a .raw file.</returns>
	public static byte[] Convert( Bitmap bitmap, HeightmapConvertSettings settings, out int resolution )
	{
		ArgumentNullException.ThrowIfNull( bitmap );
		ArgumentNullException.ThrowIfNull( settings );

		var pixels = bitmap.GetPixels();
		int count = bitmap.Width * bitmap.Height;

		// Extract the chosen channel as a float per pixel, then run the shared pipeline.
		var values = new float[count];
		for ( int i = 0; i < count; i++ )
			values[i] = SampleChannel( pixels[i], settings.Channel );

		return BuildRaw( values, bitmap.Width, bitmap.Height, settings, out resolution );
	}

	/// <summary>
	/// Convert a decoded EXR into a raw heightmap byte buffer, reading height from the
	/// chosen channel (falling back to Y/R/first channel if the exact one is absent).
	/// </summary>
	public static byte[] Convert( ExrImage exr, HeightmapConvertSettings settings, out int resolution )
	{
		ArgumentNullException.ThrowIfNull( exr );
		ArgumentNullException.ThrowIfNull( settings );

		var values = SampleChannel( exr, settings.Channel );
		return BuildRaw( values, exr.Width, exr.Height, settings, out resolution );
	}

	/// <summary>
	/// Shared pipeline: resample a single-channel float plane to a square power-of-two,
	/// normalize into 0..1, optionally invert, and quantize to raw bytes.
	/// </summary>
	static byte[] BuildRaw( float[] values, int srcWidth, int srcHeight, HeightmapConvertSettings settings, out int resolution )
	{
		resolution = ResolveResolution( srcWidth, srcHeight, settings );

		// Bilinear resample to a square of the target resolution, working entirely in float
		// so we don't lose precision on high bit-depth / HDR sources. Resampling produces a
		// fresh array; otherwise clone so the in-place normalize/invert never mutates a
		// caller-owned buffer (e.g. an EXR's cached channel plane).
		values = srcWidth != resolution || srcHeight != resolution
			? ResampleBilinear( values, srcWidth, srcHeight, resolution, resolution )
			: (float[])values.Clone();

		ApplyNormalize( values, settings.Normalize );

		if ( settings.Invert )
		{
			for ( int i = 0; i < values.Length; i++ )
				values[i] = 1f - values[i];
		}

		return Quantize( values, resolution, settings );
	}

	static float[] ResampleBilinear( float[] src, int srcW, int srcH, int dstW, int dstH )
	{
		var dst = new float[dstW * dstH];

		for ( int y = 0; y < dstH; y++ )
		{
			float fy = dstH > 1 ? (float)y / (dstH - 1) * (srcH - 1) : 0f;
			int y0 = (int)fy;
			int y1 = Math.Min( y0 + 1, srcH - 1 );
			float ty = fy - y0;

			for ( int x = 0; x < dstW; x++ )
			{
				float fx = dstW > 1 ? (float)x / (dstW - 1) * (srcW - 1) : 0f;
				int x0 = (int)fx;
				int x1 = Math.Min( x0 + 1, srcW - 1 );
				float tx = fx - x0;

				float top = MathX.Lerp( src[y0 * srcW + x0], src[y0 * srcW + x1], tx );
				float bottom = MathX.Lerp( src[y1 * srcW + x0], src[y1 * srcW + x1], tx );
				dst[y * dstW + x] = MathX.Lerp( top, bottom, ty );
			}
		}

		return dst;
	}

	static float[] SampleChannel( ExrImage exr, HeightChannel channel )
	{
		// For multi-channel EXRs, blend RGB the same way the bitmap path does. For the
		// common single-channel (Y) heightmap, every option resolves to that one plane.
		switch ( channel )
		{
			case HeightChannel.Red: return exr.GetHeightChannel( "R" );
			case HeightChannel.Green: return exr.GetHeightChannel( "G" );
			case HeightChannel.Blue: return exr.GetHeightChannel( "B" );
			case HeightChannel.Alpha: return exr.GetHeightChannel( "A" );
		}

		var r = exr.GetChannel( "R" );
		var g = exr.GetChannel( "G" );
		var b = exr.GetChannel( "B" );

		// No RGB set - it's a luminance/single-channel image, use it directly.
		if ( r is null || g is null || b is null )
			return exr.GetHeightChannel();

		var outv = new float[r.Length];
		for ( int i = 0; i < outv.Length; i++ )
		{
			outv[i] = channel switch
			{
				HeightChannel.Luminance => 0.2126f * r[i] + 0.7152f * g[i] + 0.0722f * b[i],
				HeightChannel.Average => (r[i] + g[i] + b[i]) / 3f,
				HeightChannel.Max => Math.Max( r[i], Math.Max( g[i], b[i] ) ),
				_ => r[i],
			};
		}
		return outv;
	}

	static int ResolveResolution( int width, int height, HeightmapConvertSettings settings )
	{
		int res = settings.Resolution > 0 ? settings.Resolution : Math.Min( width, height );

		if ( settings.PowerOfTwo )
			res = RoundDownToPowerOfTwo( res );

		return Math.Clamp( res, 4, 16384 );
	}

	static float SampleChannel( Color c, HeightChannel channel ) => channel switch
	{
		HeightChannel.Red => c.r,
		HeightChannel.Green => c.g,
		HeightChannel.Blue => c.b,
		HeightChannel.Alpha => c.a,
		HeightChannel.Luminance => 0.2126f * c.r + 0.7152f * c.g + 0.0722f * c.b,
		HeightChannel.Average => (c.r + c.g + c.b) / 3f,
		HeightChannel.Max => Math.Max( c.r, Math.Max( c.g, c.b ) ),
		_ => c.r,
	};

	static void ApplyNormalize( float[] values, HeightNormalize mode )
	{
		if ( mode == HeightNormalize.MinMaxStretch )
		{
			float min = values.Min();
			float max = values.Max();
			float range = max - min;

			if ( range > 1e-6f )
			{
				for ( int i = 0; i < values.Length; i++ )
					values[i] = (values[i] - min) / range;
				return;
			}
			// Flat image - fall through to clamp.
		}

		for ( int i = 0; i < values.Length; i++ )
			values[i] = Math.Clamp( values[i], 0f, 1f );
	}

	static byte[] Quantize( float[] values, int resolution, HeightmapConvertSettings settings )
	{
		bool flip = settings.FlipVertical;

		if ( settings.BitDepth == HeightBitDepth.Bit8 )
		{
			var bytes = new byte[resolution * resolution];
			for ( int y = 0; y < resolution; y++ )
			{
				int srcY = flip ? resolution - 1 - y : y;
				for ( int x = 0; x < resolution; x++ )
				{
					float v = Math.Clamp( values[srcY * resolution + x], 0f, 1f );
					bytes[y * resolution + x] = (byte)MathF.Round( v * byte.MaxValue );
				}
			}
			return bytes;
		}
		else
		{
			bool little = settings.ByteOrder == HeightByteOrder.LittleEndian;
			var bytes = new byte[resolution * resolution * 2];
			for ( int y = 0; y < resolution; y++ )
			{
				int srcY = flip ? resolution - 1 - y : y;
				for ( int x = 0; x < resolution; x++ )
				{
					float v = Math.Clamp( values[srcY * resolution + x], 0f, 1f );
					ushort h = (ushort)MathF.Round( v * ushort.MaxValue );

					int o = (y * resolution + x) * 2;
					if ( little )
					{
						bytes[o] = (byte)(h & 0xFF);
						bytes[o + 1] = (byte)(h >> 8);
					}
					else
					{
						bytes[o] = (byte)(h >> 8);
						bytes[o + 1] = (byte)(h & 0xFF);
					}
				}
			}
			return bytes;
		}
	}

	/// <summary>
	/// Rounds a value down to the nearest power of two.
	/// </summary>
	public static int RoundDownToPowerOfTwo( int value )
	{
		if ( value < 1 ) return 1;
		value |= value >> 1;
		value |= value >> 2;
		value |= value >> 4;
		value |= value >> 8;
		value |= value >> 16;
		return value - (value >> 1);
	}
}
#if DEBUG

using Sandbox.Reactivity.Internals;
using Sandbox.UI;

namespace Sandbox.Reactivity.Editor.Debugger;

// ReSharper disable once ClassNeverInstantiated.Global
[Dock("Editor", "Reactivity Debugger", "local_fire_department")]
internal sealed partial class DebuggerWidget : Widget
{
	private readonly TreeView _tree;

	public DebuggerWidget(Widget? parent)
		: base(parent)
	{
		Layout = new Column
		{
			Spacing = 2,
			Children =
			[
				new Widget
				{
					Layout = new Row
					{
						Spacing = 2,
						Children =
						[
							// TODO: search bar
							new Widget
							{
								HorizontalSizeMode = SizeMode.Flexible,
							},
							new Widget
							{
								BackgroundColor = Theme.ControlBackground,
								BorderRadius = Theme.ControlRadius,
								Layout = new Row
								{
									Children =
									[
										new ToolButton("Settings", "more_vert", this)
										{
											MouseLeftPress = () => Settings<DebuggerWidget>.OpenContextMenu(this),
										},
									],
								},
							},
						],
					},
				},
				new Widget
				{
					BackgroundColor = Theme.ControlBackground,
					BorderRadius = Theme.ControlRadius,
					Layout = new Column
					{
						Children =
						[
							_tree = new TreeView
							{
								Margin = new Margin(8, 0),
								SelectionOverride = () => EditorUtility.InspectorObject,
							},
						],
					},
				},
			],
		};

		Effect.OnEffectRootCreated += OnEffectRootCreated;
	}

	public override void OnDestroyed()
	{
		foreach (var item in _tree.Items) // .Items makes a copy
		{
			if (item is EffectTreeNode node)
			{
				node.Dispose();
			}
		}

		_tree.Clear();

		Effect.OnEffectRootCreated -= OnEffectRootCreated;
	}

	private void OnEffectRootCreated(Effect root)
	{
		if (root.IsDisposed)
		{
			// just in case - effects from other scenes (e.g. prefab editors) might cause this to happen
			return;
		}

		if (!ShowUiEffects && root.Parent is IReactivePanel)
		{
			return;
		}

		if (!ShowGameplayEffects && root.Parent is (Component or GameObject) and not IReactivePanel)
		{
			return;
		}

		var node = new EffectTreeNode(root);
		_tree.AddItem(node);

		if (AutoExpand)
		{
			_tree.Open(node, true);
		}
	}
}

#endif
#if DEBUG

using Sandbox.Reactivity.Internals;

namespace Sandbox.Reactivity.Editor.Inspector;

internal class SerializedReactiveObjectProperty(IReactiveObject reactive) : SerializedProperty
{
	protected readonly IReactiveObject ReactiveObject = reactive;

	public override string Name { get; } = reactive.Name ?? "Reactive Object";

	public override string DisplayName => Name;

	public override Type PropertyType => ReactiveObject.GetType();

	public override bool IsEditable => false;

	public override bool IsValid => ReactiveObject is not Effect { IsDisposed: true } && base.IsValid;

	public override void SetValue<T>(T value)
	{
	}

	public override T GetValue<T>(T defaultValue = default!)
	{
		return ValueToType(ReactiveObject, defaultValue);
	}
}

#endif
using System.Text.Json.Serialization;

namespace Grains.RazorDesigner.Document;

public sealed record CheckboxPayload : Payload
{
    [JsonIgnore]
    public override ControlType Kind => ControlType.Checkbox;

    // Checkbox label text. Overrides Payload.Content (neutral default "").
    public override string Content { get; init; } = "";

    public override Length CheckboxSize { get; init; } = Length.Px( 16 );
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;

namespace Grains.RazorDesigner.Inspector;

[CustomEditor( typeof( Edges ) )]
public sealed class EdgesControlWidget : ControlWidget
{
	private const string LogPrefix = "[Grains.RazorDesigner]";

	public override bool SupportsMultiEdit => true;

	private readonly EdgesProxy _proxy;
	private readonly SerializedObject _proxySerialized;
	// Synchronous change events on both sides; without this guard SetValue would loop.
	private bool _syncing;

	private sealed class EdgesProxy
	{
		public Length Top    { get; set; } = Length.Px( 0 );
		public Length Right  { get; set; } = Length.Px( 0 );
		public Length Bottom { get; set; } = Length.Px( 0 );
		public Length Left   { get; set; } = Length.Px( 0 );
	}

	public EdgesControlWidget( SerializedProperty property ) : base( property )
	{
		Log.Info( $"{LogPrefix} EdgesControlWidget ctor for {property.Name}" );

		Layout = Layout.Column();
		Layout.Spacing = 2;

		_proxy = new EdgesProxy();
		_proxySerialized = EditorTypeLibrary.GetSerializedObject( _proxy );

		var topRow = Layout.Add( Layout.Row() );
		topRow.Spacing = 2;
		AddSide( topRow, nameof( EdgesProxy.Top    ), "border_top"    );
		AddSide( topRow, nameof( EdgesProxy.Right  ), "border_right"  );

		var bottomRow = Layout.Add( Layout.Row() );
		bottomRow.Spacing = 2;
		AddSide( bottomRow, nameof( EdgesProxy.Bottom ), "border_bottom" );
		AddSide( bottomRow, nameof( EdgesProxy.Left   ), "border_left"   );

		_proxySerialized.OnPropertyChanged += OnProxyChanged;

		SyncFromProperty();
	}

	private void AddSide( Layout row, string propName, string icon )
	{
		var prop = _proxySerialized.GetProperty( propName );
		var lengthWidget = new LengthControlWidget( prop, icon );
		row.Add( lengthWidget, 1 );
	}

	protected override void PaintControl()
	{
		// nothing
	}

	private void SyncFromProperty()
	{
		if ( _syncing ) return;
		_syncing = true;

		try
		{
			var e = SerializedProperty.GetValue<Edges>( Edges.Zero );

			_proxySerialized.GetProperty( nameof( EdgesProxy.Top    ) ).SetValue( e.Top );
			_proxySerialized.GetProperty( nameof( EdgesProxy.Right  ) ).SetValue( e.Right );
			_proxySerialized.GetProperty( nameof( EdgesProxy.Bottom ) ).SetValue( e.Bottom );
			_proxySerialized.GetProperty( nameof( EdgesProxy.Left   ) ).SetValue( e.Left );
		}
		finally
		{
			_syncing = false;
		}
	}

	private void OnProxyChanged( SerializedProperty property )
	{
		if ( _syncing ) return;
		if ( ReadOnly || !SerializedProperty.IsEditable )
			return;

		_syncing = true;
		try
		{
			var newValue = new Edges( _proxy.Top, _proxy.Right, _proxy.Bottom, _proxy.Left );

			Log.Info( $"{LogPrefix} EdgesControlWidget OnProxyChanged {newValue}" );

			PropertyStartEdit();
			SerializedProperty.SetValue( newValue );
			SignalValuesChanged();
			PropertyFinishEdit();
		}
		finally
		{
			_syncing = false;
		}
	}

	protected override void OnValueChanged()
	{
		base.OnValueChanged();
		SyncFromProperty();
	}
}
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Templates;
using Sandbox;

namespace Grains.RazorDesigner.Palette;

public class PalettePanel : Widget
{
	private const string LogPrefix = "[Grains.RazorDesigner]";
	private const string CookiePrefix = "razordesigner.palette.";

	// Click-to-add target. Window decides where the new record goes (typically active selection or root).
	public event Action<ControlType> TypeAddRequested;

	// Click-to-add a saved template. Window decides where to insert.
	public event Action<PaletteTemplate> TemplateAddRequested;

	private readonly PaletteTemplateStore _templateStore = new();
	private CollapsibleSection _templatesSection;
	private WrapPanel _templatesWrap;
	public PaletteTemplateStore TemplateStore => _templateStore;

	public PalettePanel( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;
		MinimumWidth = 180;
		VerticalSizeMode = SizeMode.CanGrow;

		var byCategory = new Dictionary<ControlCategory, List<ControlType>>();
		foreach ( ControlType type in Enum.GetValues( typeof( ControlType ) ) )
		{
			var cat = ControlDefaults.For( type ).Category;
			if ( !byCategory.TryGetValue( cat, out var list ) )
			{
				list = new List<ControlType>();
				byCategory[cat] = list;
			}
			list.Add( type );
		}

		// Templates section (top of palette). Hidden when store is empty; rebuilt on Changed.
		_templatesSection = new CollapsibleSection( this, "Templates", "bookmark" );
		_templatesWrap = new WrapPanel( null )
		{
			MinItemWidth = 92,
			ItemHeight = (int)( Theme.RowHeight + 4 ),
			HSpacing = 4,
			VSpacing = 4,
			PaddingLeft = 4,
			PaddingTop = 4,
			PaddingRight = 14,
			PaddingBottom = 4,
		};
		_templatesSection.BodyLayout.Add( _templatesWrap );

		var templatesCookie = $"{CookiePrefix}templates.expanded";
		_templatesSection.Expanded = EditorCookie.Get<bool>( templatesCookie, true );
		_templatesSection.ExpandedChanged += expanded =>
		{
			EditorCookie.Set( templatesCookie, expanded );
			Log.Info( $"{LogPrefix} Palette Templates {(expanded ? "expanded" : "collapsed")}" );
		};

		Layout.Add( _templatesSection );

		_templateStore.Changed += RebuildTemplatesSection;
		_templateStore.Scan(); // initial fill (also fires Changed and rebuilds the section)

		foreach ( ControlCategory cat in Enum.GetValues( typeof( ControlCategory ) ) )
		{
			if ( !byCategory.TryGetValue( cat, out var list ) ) continue;

			var section = new CollapsibleSection(
				this,
				ControlDefaults.CategoryDisplayName( cat ),
				CategoryIcon( cat ) );

			var wrap = new WrapPanel( null )
			{
				MinItemWidth = 92,
				ItemHeight = (int)( Theme.RowHeight + 4 ),
				HSpacing = 4,
				VSpacing = 4,
				PaddingLeft = 4,
				PaddingTop = 4,
				PaddingRight = 14,    // clear the ScrollArea's vertical scrollbar
				PaddingBottom = 4,
			};
			section.BodyLayout.Add( wrap );

			foreach ( var t in list )
				new PaletteTypeButton( wrap, this, t );

			var cookieKey = $"{CookiePrefix}{cat}.expanded";
			section.Expanded = EditorCookie.Get<bool>( cookieKey, DefaultExpanded( cat ) );
			section.ExpandedChanged += expanded =>
			{
				EditorCookie.Set( cookieKey, expanded );
				Log.Info( $"{LogPrefix} Palette category {cat} {(expanded ? "expanded" : "collapsed")}" );
			};

			Layout.Add( section );
		}

		Layout.AddStretchCell();

		Log.Info( $"{LogPrefix} PalettePanel ctor (icon grid, {byCategory.Count} categories)" );
	}

	internal void NotifyTypeClicked( ControlType type )
	{
		Log.Info( $"{LogPrefix} PalettePanel.NotifyTypeClicked: {type}" );
		TypeAddRequested?.Invoke( type );
	}

	internal void NotifyTemplateClicked( PaletteTemplate template )
	{
		Log.Info( $"{LogPrefix} PalettePanel.NotifyTemplateClicked: \"{template.Name}\"" );
		TemplateAddRequested?.Invoke( template );
	}

	internal void RequestTemplateDelete( PaletteTemplate template )
	{
		var dialog = new Editor.Dialog( this );
		dialog.Window.WindowTitle = "Delete template";
		dialog.Window.SetWindowIcon( "delete" );
		dialog.Window.SetModal( true, true );
		dialog.Window.MinimumWidth = 320;

		dialog.Layout = Layout.Column();
		dialog.Layout.Margin = 16;
		dialog.Layout.Spacing = 10;

		dialog.Layout.Add( new Editor.Label( dialog )
		{
			Text = $"Delete template \"{template.Name}\"?",
		} );

		var hint = new Editor.Label( dialog )
		{
			Text = "Already-instantiated copies in open documents are unaffected.",
		};
		hint.SetStyles( "color: #888; font-size: 11px;" );
		dialog.Layout.Add( hint );

		var buttonRow = dialog.Layout.Add( Layout.Row() );
		buttonRow.Spacing = 6;
		buttonRow.AddStretchCell();

		var cancel = new Editor.Button( dialog ) { Text = "Cancel", MinimumWidth = 72 };
		cancel.MouseLeftPress += () => dialog.Close();
		buttonRow.Add( cancel );

		var del = new Editor.Button( dialog ) { Text = "Delete", MinimumWidth = 72 };
		del.SetStyles( "color: #e07070;" );
		del.MouseLeftPress += () =>
		{
			Log.Info( $"{LogPrefix} Palette delete confirmed: \"{template.Name}\"" );
			_templateStore.Delete( template );
			dialog.Close();
		};
		buttonRow.Add( del );

		dialog.Window.AdjustSize();
		dialog.Show();
	}

	private void RebuildTemplatesSection()
	{
		var templates = _templateStore.All;

		// Hide the entire section (header + body) when there are no templates.
		_templatesSection.Visible = templates.Count > 0;

		using ( Editor.SuspendUpdates.For( _templatesWrap ) )
		{
			_templatesWrap.DestroyChildren();
			foreach ( var t in templates )
				new PaletteTemplateButton( _templatesWrap, this, t );
		}

		_templatesWrap.Relayout();
		_templatesWrap.UpdateGeometry();
		_templatesSection.UpdateGeometry();
		UpdateGeometry();

		Log.Info( $"{LogPrefix} PalettePanel.RebuildTemplatesSection: {templates.Count} tile(s), section.Visible={_templatesSection.Visible}" );
	}

	private static bool DefaultExpanded( ControlCategory cat ) =>
		cat is ControlCategory.Layout or ControlCategory.Display or ControlCategory.Input;

	private static string CategoryIcon( ControlCategory cat ) => cat switch
	{
		ControlCategory.Layout   => "view_quilt",
		ControlCategory.Display  => "visibility",
		ControlCategory.Input    => "edit",
		ControlCategory.Form     => "list_alt",
		_ => "category",
	};

	private sealed class PaletteTypeButton : Widget
	{
		private readonly PalettePanel _owner;
		private readonly ControlType _type;
		// InspectorIcon comes from the contract (engine-fidelity); drag defaults from ControlDefaults.
		private readonly string _icon;

		public PaletteTypeButton( Widget parent, PalettePanel owner, ControlType type ) : base( parent )
		{
			_owner = owner;
			_type = type;
			_icon = ContractScanner.Table.Get( type ).InspectorIcon;

			ToolTip = type.ToString();
			Cursor = CursorShape.Finger;
			MouseTracking = true;
			IsDraggable = true;
		}

		protected override void OnPaint()
		{
			var rect = LocalRect.Shrink( 1 );
			Paint.Antialiasing = true;
			Paint.TextAntialiasing = true;

			var tint = ControlPresentation.IconTint( _type );
			var fillAlpha = Paint.HasMouseOver ? 0.35f : 0.15f;
			var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
			Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
			Paint.SetPen( tint.WithAlpha( borderAlpha ) );
			Paint.DrawRect( rect, 3 );

			var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
			var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
			Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
			Paint.DrawIcon( iconRect, _icon, 16, TextFlag.Center );

			var textRect = rect;
			textRect.Left = iconRect.Right + 2;
			textRect.Right -= 4;
			Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
			Paint.SetDefaultFont();
			Paint.DrawText( textRect, _type.ToString(), TextFlag.LeftCenter );
		}

		protected override void OnMouseClick( MouseEvent e )
		{
			base.OnMouseClick( e );
			if ( e.LeftMouseButton )
				_owner.NotifyTypeClicked( _type );
		}

		protected override void OnDragStart()
		{
			base.OnDragStart();

			var drag = new Drag( this );
			drag.Data.Object = _type;
			drag.Data.Text = $"palette:{_type}";
			drag.Execute();

			Log.Info( $"{LogPrefix} PaletteTypeButton.OnDragStart: {_type}" );
		}
	}

	private sealed class PaletteTemplateButton : Widget
	{
		private readonly PalettePanel _owner;
		private readonly PaletteTemplate _template;

		public PaletteTemplateButton( Widget parent, PalettePanel owner, PaletteTemplate template ) : base( parent )
		{
			_owner = owner;
			_template = template;

			ToolTip = template.Name;
			Cursor = CursorShape.Finger;
			MouseTracking = true;
			IsDraggable = true;
		}

		protected override void OnPaint()
		{
			var rect = LocalRect.Shrink( 1 );
			Paint.Antialiasing = true;
			Paint.TextAntialiasing = true;

			var tint = ControlPresentation.TemplateTint;
			var fillAlpha = Paint.HasMouseOver ? 0.18f : 0.08f;
			var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
			Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
			Paint.SetPen( tint.WithAlpha( borderAlpha ) );
			Paint.DrawRect( rect, 3 );

			var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
			var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
			var icon = string.IsNullOrEmpty( _template.IconName ) ? "bookmark" : _template.IconName;
			Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
			Paint.DrawIcon( iconRect, icon, 16, TextFlag.Center );

			var textRect = rect;
			textRect.Left = iconRect.Right + 2;
			textRect.Right -= 4;
			Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
			Paint.SetDefaultFont();
			Paint.DrawText( textRect, _template.Name, TextFlag.LeftCenter );
		}

		protected override void OnMouseClick( MouseEvent e )
		{
			base.OnMouseClick( e );
			if ( e.LeftMouseButton )
				_owner.NotifyTemplateClicked( _template );
		}

		protected override void OnContextMenu( ContextMenuEvent e )
		{
			base.OnContextMenu( e );
			var menu = new Menu( this );
			menu.AddOption( "Delete…", "delete", () => _owner.RequestTemplateDelete( _template ) );
			menu.OpenAtCursor();
			e.Accepted = true;
		}

		protected override void OnDragStart()
		{
			base.OnDragStart();
			var drag = new Drag( this );
			drag.Data.Object = _template;
			drag.Data.Text = $"template:{_template.Name}";
			drag.Execute();
			Log.Info( $"{LogPrefix} PaletteTemplateButton.OnDragStart: \"{_template.Name}\"" );
		}
	}
}
namespace Grains.RazorDesigner.Projection.CSharp;

public abstract record CSharpOp;

// File-level scaffold
public sealed record HeaderBanner( string ClassName, string Namespace ) : CSharpOp;
public sealed record UsingDirective( string Namespace ) : CSharpOp;
public sealed record NamespaceOpen( string Namespace ) : CSharpOp;
public sealed record ClassOpen( string ClassName, string BaseClass ) : CSharpOp;
public sealed record ClassClose() : CSharpOp;

public sealed record FieldDecl(
    string Visibility, string Type, string Name, string InitialExpr,
    bool IsParameter, bool IsProperty = false ) : CSharpOp;

public sealed record MethodOpen(
    string Visibility, bool IsOverride, bool IsAsync,
    string ReturnType, string Name, string ParameterList ) : CSharpOp;

public sealed record MethodClose() : CSharpOp;

// Body-level
public sealed record Statement( string Code ) : CSharpOp;          // single `;`-terminated line
public sealed record BlockOpen( string Header ) : CSharpOp;        // e.g. `if ( <cond> )` — applier writes "<header> {\n" and indents
public sealed record BlockClose() : CSharpOp;
public sealed record BlankLine() : CSharpOp;
public sealed record Comment( string Text ) : CSharpOp;            // `// <text>`
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.CSharp.Projectors;

namespace Grains.RazorDesigner.Projection.CSharp;

public static class CSharpProjector
{
    public static CSharpResult Project(
        IReadOnlyWiring wiring,
        bool documentHasAnyBindings )
    {
        if ( wiring.Symbols.Count == 0 && !documentHasAnyBindings )
            return new CSharpResult( System.Array.Empty<CSharpOp>(), null );

        var ctx = new CSharpProjectorContext( wiring );
        var ops = new List<CSharpOp>( 64 );

        ops.Add( new HeaderBanner( wiring.ClassName, wiring.Namespace ) );
        ops.Add( new UsingDirective( "Sandbox" ) );
        ops.Add( new UsingDirective( "Sandbox.UI" ) );
        foreach ( var u in wiring.Usings )
            ops.Add( new UsingDirective( u ) );
        ops.Add( new NamespaceOpen( wiring.Namespace ) );
        ops.Add( new ClassOpen( wiring.ClassName, wiring.BaseClass ) );

        // Step 3: body. SymbolProjector handles grouping + sorting + per-kind dispatch.
        SymbolProjector.EmitAll( wiring, ops, ctx );

        ops.Add( new ClassClose() );

        var source = CSharpApplier.Apply( ops ).Replace( "\r\n", "\n" );

        return new CSharpResult( ops, source );
    }
}
using System.Collections.Generic;
using Grains.RazorDesigner.Wiring;

namespace Grains.RazorDesigner.Projection.CSharp.Projectors;

public static class ParameterSymbolProjector
{
    public static void Emit( ParameterSymbol s, List<CSharpOp> ops, CSharpProjectorContext ctx )
    {
        var initial = s.Initial is null ? "default" : ExpressionEmitter.Emit( s.Initial, ctx );
        ops.Add( new FieldDecl(
            Visibility: "public", Type: s.Type, Name: s.Name,
            InitialExpr: initial, IsParameter: true ) );
    }
}
namespace Grains.RazorDesigner.Projection;

public static class Escape
{
    public static string Html( string s )
    {
        if ( string.IsNullOrEmpty( s ) ) return "";
        return s
            .Replace( "&", "&amp;" )
            .Replace( "<", "&lt;" )
            .Replace( ">", "&gt;" )
            .Replace( "\"", "&quot;" );
    }
}
using System;
using System.Collections.Generic;
using Sandbox; // Color

namespace Grains.RazorDesigner.Projection;

public interface IReadOnlyStateRule
{
    Document.PseudoKind State { get; }
    Document.NthChildMode NthChildMode { get; }
    int NthChildArg { get; }
    IAppearance Delta { get; }

    public static int CompareCanonical( IReadOnlyStateRule a, IReadOnlyStateRule b )
    {
        int c = ((int)a.State).CompareTo( (int)b.State );
        if ( c != 0 ) return c;
        c = ((int)a.NthChildMode).CompareTo( (int)b.NthChildMode );
        if ( c != 0 ) return c;
        return a.NthChildArg.CompareTo( b.NthChildArg );
    }
}

public interface IReadOnlyNode
{
    Guid Id { get; }
    string Kind { get; }         // == ControlType.ToString()
    string ClassName { get; }
    IAppearance Appearance { get; }
    IPayload Payload { get; }
    IReadOnlyList<IReadOnlyNode> Children { get; }                              // non-slot children
    IReadOnlyDictionary<string, IReadOnlyList<IReadOnlyNode>> Slots { get; }    // slot-name -> slot children (only SplitContainer populates)
    IReadOnlyList<IReadOnlyStateRule> StateRules { get; }                       // per-state style deltas; canonical order not guaranteed here (the Applier sorts)
}

public interface IAppearance
{
    // Layout
    Document.Length Width { get; }
    Document.Length Height { get; }

    // Flex container
    Document.FlexDirection Direction { get; }
    Document.JustifyContent Justify { get; }
    Document.AlignItems Align { get; }
    float Gap { get; }
    Document.Edges Padding { get; }
    Document.FlexWrap Wrap { get; }

    // Positioning (grd-7t2z)
    Document.PositionKind Position { get; }
    Document.Length Top { get; }
    Document.Length Left { get; }
    Document.Length Right { get; }
    Document.Length Bottom { get; }

    // Flex self
    float FlexGrow { get; }
    float FlexShrink { get; }
    Document.Length FlexBasis { get; }
    Document.AlignSelfKind AlignSelf { get; }

    // Typography + OverrideTypography
    bool OverrideTypography { get; }
    string FontFamily { get; }
    Document.Length FontSize { get; }
    int FontWeight { get; }
    Color Color { get; }
    Document.TextAlignment TextAlign { get; }
    bool FontStyleItalic { get; }
    Document.TextTransformKind TextTransform { get; }
    Document.Length LetterSpacing { get; }
    Document.Length LineHeight { get; }

    // Background + OverrideBackground
    bool OverrideBackground { get; }
    Color BackgroundColor { get; }
    string BackgroundImage { get; }
    string BackgroundSize { get; }
    string BackgroundPosition { get; }
    string BackgroundRepeat { get; }

    // Border + OverrideBorder
    bool OverrideBorder { get; }
    Document.Length BorderRadius { get; }
    Color BorderColor { get; }
    Document.Length BorderWidth { get; }

    // Effects + OverrideEffects
    bool OverrideEffects { get; }
    Document.Length BoxShadowX { get; }
    Document.Length BoxShadowY { get; }
    Document.Length BoxShadowBlur { get; }
    Color BoxShadowColor { get; }
    bool BoxShadowInset { get; }
    float Opacity { get; }

    // Constraints + OverrideConstraints
    bool OverrideConstraints { get; }
    Document.Edges Margin { get; }
    Document.Length MinWidth { get; }
    Document.Length MaxWidth { get; }
    Document.Length MinHeight { get; }
    Document.Length MaxHeight { get; }

    // Interaction + OverrideInteraction
    bool OverrideInteraction { get; }
    Document.CursorKind Cursor { get; }
    Document.OverflowKind Overflow { get; }
    int ZIndex { get; }
    bool PointerEvents { get; }
}

public interface IPayload
{
    string Content { get; }       // Label/Button text; Checkbox label
    string Placeholder { get; }   // TextEntry
    string Source { get; }        // Image src
    string IconName { get; }      // IconPanel glyph
    Document.Length CheckboxSize { get; }  // Checkbox box size
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;

namespace Grains.RazorDesigner.Projection.Projectors;

[Projector( "Button" )]
public sealed class ButtonProjector : IControlProjector
{
    public string Kind => "Button";

    public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
    {
        var scss = AppearanceScss.Emit(
            a,
            isRoot:       node.ClassName == Document.DesignerDocument.RootClassName,
            isContainer:  false,
            childCount:   0,
            isLabel:      false,
            isCheckbox:   false,
            checkboxSize: default );

        var nodeId = node.Id.ToString();
        var ops = new PanelOp[]
        {
            new SetAttribute( "data-grd-node-id", nodeId ),
            new SetInnerText( p.Content ?? "" ),
        };

        var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };

        return new ProjectionResult(
            PanelOps:        ops,
            ScssLines:       scss,
            RazorAttributes: razorAttrs,
            RazorInnerText:  Escape.Html( p.Content ?? "" ) );
    }
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;

namespace Grains.RazorDesigner.Projection.Projectors;

[Projector( "Field" )]
public sealed class FieldProjector : IControlProjector
{
    public string Kind => "Field";

    public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
    {
        var scss = AppearanceScss.Emit(
            a,
            isRoot:       node.ClassName == Document.DesignerDocument.RootClassName,
            isContainer:  true,
            childCount:   node.Children.Count,
            isLabel:      false,
            isCheckbox:   false,
            checkboxSize: default );

        var nodeId = node.Id.ToString();
        var ops = new PanelOp[]
        {
            new SetAttribute( "data-grd-node-id", nodeId ),
        };

        var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };

        return new ProjectionResult(
            PanelOps:        ops,
            ScssLines:       scss,
            RazorAttributes: razorAttrs,
            RazorInnerText:  null );
    }
}
namespace Grains.RazorDesigner.Projection.Razor;

public static class RazorEmit
{
    public static string Attr( string name, string value ) => $"{name}=\"{Escape.Html( value )}\"";
}
using System;

namespace Grains.RazorDesigner.Projection.Tests;

public static class PanelOpExhaustivenessTest
{
    public static (bool pass, string message) Run()
    {
        var ops = new PanelOp[]
        {
            new SetClass( "" ),
            new SetStyle( "", "" ),
            new SetAttribute( "", "" ),
            new SetInnerText( "" ),
        };
        try
        {
            foreach ( var op in ops )
                Applier.ApplyOpToScratch( op );
            return (true, $"PanelOpExhaustivenessTest: {ops.Length} variants OK");
        }
        catch ( Exception e )
        {
            return (false, $"PanelOpExhaustivenessTest FAILED: {e.GetType().Name}: {e.Message}");
        }
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Grains.RazorDesigner.Document;

namespace Grains.RazorDesigner.Serialization.IR;

public static class IRWriter
{
	private const string LogPrefix = "[Grains.RazorDesigner]";

	// Empty collections reused for nodes that have no slots/children/metadata.
	private static readonly IReadOnlyDictionary<string, object> _emptyMetadata = new Dictionary<string, object>();
	private static readonly IReadOnlyList<IRNodeEnvelope>      _emptyChildren = System.Array.Empty<IRNodeEnvelope>();
	private static readonly IReadOnlyDictionary<string, IRNodeEnvelope> _emptySlots = new Dictionary<string, IRNodeEnvelope>();

	public static string WriteDocument( DesignerDocument doc )
	{
		if ( doc is null )
			throw new ArgumentNullException( nameof( doc ) );

		Log.Info( $"{LogPrefix} IRWriter.WriteDocument: serialising document (root children: {doc.RootRecord.Children.Count})" );

		var envelope = new IRDocumentEnvelope
		{
			Root   = ToNode( doc.RootRecord ),
			Wiring = doc.Wiring ?? Grains.RazorDesigner.Wiring.WiringEnvelope.Empty,
		};

		var json = JsonSerializer.Serialize( envelope, DesignerIRJson.Options );

		// Normalise CRLF → LF (canonical form; .gitattributes also pins LF as a backstop).
		if ( json.Contains( '\r' ) )
			json = json.Replace( "\r\n", "\n" ).Replace( "\r", "\n" );

		Log.Info( $"{LogPrefix} IRWriter.WriteDocument: OK ({json.Length} chars)" );
		return json;
	}

	public static string CanonicalHash( string json )
	{
		if ( json is null )
			throw new ArgumentNullException( nameof( json ) );

		var bytes = Encoding.UTF8.GetBytes( json );
		var hash  = SHA256.HashData( bytes );
		return Convert.ToHexString( hash ).ToLowerInvariant();
	}

	// Recursively converts a ControlRecord to its IRNodeEnvelope representation.
	private static IRNodeEnvelope ToNode( ControlRecord r )
	{
		Dictionary<string, IRNodeEnvelope> slotDict  = null;
		List<IRNodeEnvelope>               childList = null;

		foreach ( var child in r.Children )
		{
			if ( child.IsSlot )
			{
				slotDict ??= new Dictionary<string, IRNodeEnvelope>();
				slotDict[child.SlotName] = ToNode( child );
			}
			else
			{
				childList ??= new List<IRNodeEnvelope>();
				childList.Add( ToNode( child ) );
			}
		}

		return new IRNodeEnvelope
		{
			Id         = r.Id,
			Kind       = r.Type,
			ClassName  = r.ClassName,
			Appearance = r.Appearance,
			Payload    = r.Payload,
			Slots      = slotDict  is not null
				? (IReadOnlyDictionary<string, IRNodeEnvelope>)slotDict
				: _emptySlots,
			Children   = childList is not null
				? (IReadOnlyList<IRNodeEnvelope>)childList
				: _emptyChildren,
			States = r.StateRules.Count == 0
				? null
				: r.StateRules
					.OrderBy( rule => rule, Comparer<StateRule>.Create( StateRule.CompareCanonical ) )
					.Select( rule => new IRStateEnvelope
					{
						State       = rule.State,
						NthChildMode = rule.NthChildMode,
						NthChildArg  = rule.NthChildArg,
						Delta        = rule.Delta,
					} )
					.ToList(),
			Bindings   = r.Bindings.Count == 0
				? System.Array.Empty<Grains.RazorDesigner.Wiring.Binding>()
				: r.Bindings.ToArray(),

				CustomStyles = r.CustomStyles.Count == 0 
				? null 
				: new Dictionary<string, string>( r.CustomStyles ),
		};
	}
}
using System;
using System.Text.Json.Serialization;

namespace Grains.RazorDesigner.Wiring;

[JsonPolymorphic( TypeDiscriminatorPropertyName = "$type" )]
[JsonDerivedType( typeof( SetAction ),              "Set" )]
[JsonDerivedType( typeof( CallAction ),             "Call" )]
[JsonDerivedType( typeof( IfAction ),               "If" )]
[JsonDerivedType( typeof( StateHasChangedAction ),  "StateHasChanged" )]
[JsonDerivedType( typeof( LogAction ),              "Log" )]
[JsonDerivedType( typeof( ReturnAction ),           "Return" )]
[JsonDerivedType( typeof( InlineAction ),           "Inline" )]
public abstract record Action
{
    public Guid Id { get; init; } = Guid.NewGuid();
}
namespace Grains.RazorDesigner.Wiring;

public sealed record InlineAction : Action
{
    public string Code { get; init; } = "";
}
namespace Grains.RazorDesigner.Wiring;

// `Target = Value;` — assignment to a Symbol field.
public sealed record SetAction : Action
{
    public TargetRef Target { get; init; }
    public Expression Value { get; init; }
}
using System.Collections.Generic;

namespace Grains.RazorDesigner.Wiring;

public sealed record EventBinding : Binding
{
    public string Event { get; init; } = "";
    public IReadOnlyList<Action> Body { get; init; } = System.Array.Empty<Action>();
}
namespace Grains.RazorDesigner.Wiring;

public sealed record VisibleBinding : Binding
{
    public Expression Condition { get; init; }
}
namespace Grains.RazorDesigner.Wiring;

public enum SymbolVisibility
{
    Private,
    Public,
    Internal,
    Protected,
}
// Unattended editor-process gate.
//
// Runs ONLY when AUTORIG_GATE_RESULT is set AND its .arm marker file exists (the
// driver script dev/editor-rig/run_editor_gate.ps1 writes the marker immediately
// before launch and this hook consumes it - an env var leaked into Steam's
// environment must never arm the gate in a user's session; pattern proven in
// humanoid-retargeter's M0Gate).
//
// Flow inside a real sbox-dev.exe session:
//   1. wait for the project + asset system
//   2. refuse to run unless the open project is the autorig-editor-rig scratch
//   3. load the input model (AUTORIG_GATE_INPUT), analyze, rig, export
//   4. write fbx + vmdl into the scratch Assets, register + compile
//   5. load the compiled model, record bone count, write JSON, quit

using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutoRig.Analyze;
using AutoRig.Formats;
using AutoRig.Solve;
using Editor;
using Sandbox;

namespace AutoRig.Editor.Gate;

public static class AutoRigGate
{
    static bool _started;
    static readonly GateResult Result = new();
    static string _resultPath;

    [EditorEvent.Frame]
    public static void Tick()
    {
        if ( _started )
            return;
        _started = true;

        _resultPath = Environment.GetEnvironmentVariable( "AUTORIG_GATE_RESULT" );
        if ( string.IsNullOrWhiteSpace( _resultPath ) )
            return; // not a gate run

        var marker = _resultPath + ".arm";
        try
        {
            if ( !File.Exists( marker ) )
            {
                Log.Info( "[autorig-gate] AUTORIG_GATE_RESULT set but no arming marker - ignoring (leaked env var)" );
                return;
            }
            File.Delete( marker );
        }
        catch
        {
            return; // cannot verify the marker - never run
        }

        _ = RunAsync();
    }

    static async Task RunAsync()
    {
        Note( "gate starting" );
        Result.engineBooted = true;
        Flush();

        try
        {
            await RunGateAsync();
        }
        catch ( Exception e )
        {
            Note( $"EXCEPTION: {e}" );
        }

        Result.completed = true;
        Result.passed = Result.vmdlCompiled && Result.modelLoads && Result.boneCount >= 2;
        Flush();
        Note( $"gate finished, passed={Result.passed}" );

        if ( Result.refusedWrongProject )
            return;

        await Task.Delay( 1000 );
        try
        {
            EditorUtility.Quit( true );
        }
        catch ( Exception e )
        {
            Note( $"EditorUtility.Quit threw: {e.Message}" );
            Flush();
        }
        await Task.Delay( 10_000 );
        Environment.Exit( Result.passed ? 0 : 1 );
    }

    static async Task RunGateAsync()
    {
        Result.assetSystemReady = await WaitUntil(
            () => Project.Current is not null && AssetSystem.All.Any(),
            timeoutSeconds: 120 );
        Flush();
        if ( !Result.assetSystemReady )
            return;

        var project = Project.Current;
        Result.projectPath = project.GetRootPath();

        // Never touch a real session's project.
        if ( (Result.projectPath ?? "").IndexOf( "autorig-editor-rig", StringComparison.OrdinalIgnoreCase ) < 0 )
        {
            Result.refusedWrongProject = true;
            Note( $"REFUSING to run: open project '{Result.projectPath}' is not the autorig-editor-rig scratch" );
            Flush();
            return;
        }

        // A fresh scratch project spends its first minute scanning/importing template
        // content; compiles queued during that window can stall. Let it settle.
        Note( "waiting for the initial asset scan to settle" );
        await Task.Delay( 30_000 );

        var inputPath = Environment.GetEnvironmentVariable( "AUTORIG_GATE_INPUT" );
        if ( string.IsNullOrWhiteSpace( inputPath ) || !File.Exists( inputPath ) )
        {
            Note( $"input model not found (AUTORIG_GATE_INPUT='{inputPath}')" );
            Flush();
            return;
        }

        // ---- analyze + rig + export (the same code path the window uses) ----
        // Unique per-run name: compiled artifacts of previous runs can neither be
        // deleted (the engine holds them open) nor trusted (Model.Load caches by
        // path) - fresh names sidestep both.
        var runTag = (Environment.TickCount & 0xFFFFF).ToString();
        var bytes = await Task.Run( () => File.ReadAllBytes( inputPath ) );
        var fileName = Path.GetFileName( inputPath );
        var (rigSolver, jointCount, bundle) = await Task.Run( () =>
        {
            var mesh = MeshLoader.Load( bytes, fileName );
            var analysis = MeshAnalyzer.Analyze( mesh );
            var rig = AutoRigger.Rig( analysis );
            var exported = AutoRig.Export.RigExporter.Export(
                mesh, rig, $"{Path.GetFileNameWithoutExtension( fileName )}_{runTag}",
                "autorig_gate" );
            return (rig.SolverName, rig.Skeleton.Joints.Count, exported);
        } );
        Result.solver = rigSolver;
        Result.rigJointCount = jointCount;
        Result.rigged = true;
        Note( $"rigged via {rigSolver}: {jointCount} joints" );
        Flush();

        var gateDir = Path.Combine( project.GetAssetsPath(), "autorig_gate" );
        Directory.CreateDirectory( gateDir );
        var fbxPath = Path.Combine( gateDir, bundle.FbxFileName );
        var vmdlPath = Path.Combine( gateDir, bundle.VmdlFileName );
        File.WriteAllBytes( fbxPath, bundle.Fbx );
        File.WriteAllText( vmdlPath, bundle.Vmdl.Replace(
            bundle.FbxFileName, $"autorig_gate/{bundle.FbxFileName}" ) );
        foreach ( var (extraName, extraBytes) in bundle.ExtraFiles )
        {
            var extraPath = Path.Combine( gateDir, extraName );
            File.WriteAllBytes( extraPath, extraBytes );
            AssetSystem.RegisterFile( extraPath );
            Note( $"wrote companion {extraName}" );
        }
        Result.filesWritten = true;
        Note( $"wrote {fbxPath} + {vmdlPath}" );
        Flush();

        // ---- register + load (compile-on-demand) ----
        // Model.Load compiles uncompiled assets synchronously and honestly;
        // Asset.Compile(full) queues externally-written files but never processes
        // them in this headless flow (observed: fresh compiles time out while
        // Model.Load of the same asset succeeds in milliseconds).
        AssetSystem.RegisterFile( fbxPath );
        var asset = AssetSystem.RegisterFile( vmdlPath );
        Result.assetRegistered = asset is not null;
        Flush();
        if ( asset is null )
            return;

        Note( "loading model (compile-on-demand)" );
        Flush();
        var model = Model.Load( asset.Path );
        Result.vmdlCompiled = model is not null && !model.IsError;
        Note( $"vmdlCompiled={Result.vmdlCompiled}" );
        Flush();
        if ( !Result.vmdlCompiled )
            return;
        Result.modelLoads = model is not null && !model.IsError;
        if ( model is not null )
        {
            Result.boneCount = model.BoneCount;
            Result.meshBoundsSize = model.Bounds.Size.Length;
        }
        Note( $"modelLoads={Result.modelLoads} bones={Result.boneCount} "
            + $"boundsSize={Result.meshBoundsSize:0.###} (rig had {Result.rigJointCount})" );
        Flush();

        // ---- control experiments: pinpoint which layer loses the mesh ----
        // (a) the ORIGINAL corpus fbx (Blender-exported, known-good format)
        Result.controlOriginalBounds = await CompileControl( $"ctl_original_{runTag}",
            File.ReadAllBytes( inputPath ) );
        // (b) the original PARSED by our tokenizer and REWRITTEN by our writer
        try
        {
            var rewritten = AutoRig.Formats.Fbx.FbxWriter.Write(
                AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) ) );
            Result.controlRewriteBounds = await CompileControl( $"ctl_rewrite_{runTag}", rewritten );
        }
        catch ( Exception e )
        {
            Note( $"rewrite control failed: {e.Message}" );
        }
        // (c) our writer, mesh+material only (no skeleton/skin objects)
        try
        {
            var meshOnly = await Task.Run( () =>
            {
                var m = MeshLoader.Load( bytes, fileName );
                var a = MeshAnalyzer.Analyze( m );
                var r = AutoRigger.Rig( a );
                return AutoRig.Export.FbxRigWriter.Write( m, r, "ctl_meshonly", includeSkeleton: false );
            } );
            Result.controlMeshOnlyBounds = await CompileControl( $"ctl_meshonly_{runTag}", meshOnly );
        }
        catch ( Exception e )
        {
            Note( $"meshonly control failed: {e.Message}" );
        }
        // (d)/(e) surgical swaps INTO the working original: which scaffolding
        // section of ours poisons the import?
        try
        {
            var parsedOriginal = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            Result.controlDocSwapBounds = await CompileControl( $"ctl_docswap_{runTag}",
                SwapSection( parsedOriginal, "Documents", BuildOurDocuments() ) );

            parsedOriginal = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            Result.controlDefsSwapBounds = await CompileControl( $"ctl_defswap_{runTag}",
                SwapSection( parsedOriginal, "Definitions", BuildMinimalDefinitions() ) );
        }
        catch ( Exception e )
        {
            Note( $"swap controls failed: {e.Message}" );
        }

        // (f) original scaffolding + OUR geometry node transplanted into the original
        // (keeps the original's geometry id so connections stay valid).
        try
        {
            var host = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourFile = await Task.Run( () =>
            {
                var m = MeshLoader.Load( bytes, fileName );
                var a = MeshAnalyzer.Analyze( m );
                var r = AutoRigger.Rig( a );
                return AutoRig.Formats.Fbx.FbxTokenizer.Parse(
                    AutoRig.Export.FbxRigWriter.Write( m, r, "donor", includeSkeleton: false ) );
            } );
            var hostObjects = host.Child( "Objects" );
            var ourGeometry = ourFile.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            for ( var i = 0; i < hostObjects.Children.Count; i++ )
            {
                if ( hostObjects.Children[i].Name != "Geometry" )
                    continue;
                var originalId = hostObjects.Children[i].Properties[0];
                ourGeometry.Properties[0] = originalId; // preserve identity for connections
                hostObjects.Children[i] = ourGeometry;
                break; // first geometry only
            }
            Result.controlGeoSwapBounds = await CompileControl( $"ctl_geoswap_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host ) );
        }
        catch ( Exception e )
        {
            Note( $"geoswap control failed: {e.Message}" );
        }

        // (g) original file, first geometry STRIPPED to our writer's node set
        // (their data, our structure): distinguishes "we omit a required node"
        // from "our array data is rejected".
        try
        {
            var host = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var hostGeometry = host.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            var keep = new[]
            {
                "Properties70", "GeometryVersion", "Vertices", "PolygonVertexIndex",
                "LayerElementNormal", "LayerElementMaterial",
            };
            hostGeometry.Children.RemoveAll( c => !keep.Contains( c.Name ) && c.Name != "Layer" );
            var hostLayer = hostGeometry.Children.FirstOrDefault( c => c.Name == "Layer" );
            hostLayer?.Children.RemoveAll( c =>
            {
                if ( c.Name != "LayerElement" )
                    return false;
                var type = c.Children.FirstOrDefault( t => t.Name == "Type" );
                var typeName = type?.Properties.Count > 0 ? type.Properties[0] as string : null;
                return typeName is not ("LayerElementNormal" or "LayerElementMaterial");
            } );
            Result.controlStripBounds = await CompileControl( $"ctl_strip_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host ) );
        }
        catch ( Exception e )
        {
            Note( $"strip control failed: {e.Message}" );
        }

        // (h) the stripped host again, but with OUR generated arrays transplanted
        // into THEIR geometry node (Vertices/PolygonVertexIndex/Normals): if this
        // fails, our array DATA is what the importer rejects; if it imports, only
        // node names/metadata remain as suspects.
        try
        {
            var host = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var hostGeometry = host.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            var keep = new[]
            {
                "Properties70", "GeometryVersion", "Vertices", "PolygonVertexIndex",
                "LayerElementNormal", "LayerElementMaterial", "Layer",
            };
            hostGeometry.Children.RemoveAll( c => !keep.Contains( c.Name ) );
            TrimLayerNode( hostGeometry );

            // Build our tank arrays.
            var tankBytes = await Task.Run( () =>
            {
                var m = MeshLoader.Load( bytes, fileName );
                var a = MeshAnalyzer.Analyze( m );
                var r = AutoRigger.Rig( a );
                return AutoRig.Export.FbxRigWriter.Write( m, r, "arrays", includeSkeleton: false );
            } );
            var ourGeometry = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes )
                .Child( "Objects" ).Children.First( c => c.Name == "Geometry" );

            foreach ( var arrayName in new[] { "Vertices", "PolygonVertexIndex" } )
            {
                var source = ourGeometry.Children.First( c => c.Name == arrayName );
                var target = hostGeometry.Children.First( c => c.Name == arrayName );
                target.Properties[0] = source.Properties[0];
            }
            // Scale x3 so success is unambiguous: imported → bounds ~2.7, dropped → 0.897.
            var transplantedVertices = (double[])hostGeometry.Children
                .First( c => c.Name == "Vertices" ).Properties[0];
            for ( var i = 0; i < transplantedVertices.Length; i++ )
                transplantedVertices[i] *= 3.0;
            var ourNormals = ourGeometry.Children.First( c => c.Name == "LayerElementNormal" )
                .Children.First( c => c.Name == "Normals" );
            hostGeometry.Children.First( c => c.Name == "LayerElementNormal" )
                .Children.First( c => c.Name == "Normals" ).Properties[0] = ourNormals.Properties[0];

            Result.controlDataSwapBounds = await CompileControl( $"ctl_g1_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host ) );

            // (i) our meshonly WITHOUT the UV layer - the UV layer is present in every
            // failing file and absent from every passing one.
            var noUv = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
            var noUvGeometry = noUv.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            noUvGeometry.Children.RemoveAll( c => c.Name == "LayerElementUV" );
            var noUvLayer = noUvGeometry.Children.FirstOrDefault( c => c.Name == "Layer" );
            noUvLayer?.Children.RemoveAll( c =>
            {
                if ( c.Name != "LayerElement" )
                    return false;
                var type = c.Children.FirstOrDefault( t => t.Name == "Type" );
                return type?.Properties.Count > 0 && (string)type.Properties[0] == "LayerElementUV";
            } );
            Result.controlNoUvBounds = await CompileControl( $"ctl_nouv_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( noUv ) );

            // (j) our meshonly with the geometry renamed to the original's name -
            // the last remaining metadata delta.
            var renamed = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
            renamed.Child( "Objects" ).Children.First( c => c.Name == "Geometry" )
                .Properties[1] = "Cube.006\0\x01Geometry";
            Result.controlRenameBounds = await CompileControl( $"ctl_name_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( renamed ) );

            // (k) THEIR file + our geometry WITHOUT its UV layer (geoswap minus UV):
            // isolates the UV layer as an in-geometry poison.
            var host2 = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourGeoNoUv = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes )
                .Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            ourGeoNoUv.Children.RemoveAll( c => c.Name == "LayerElementUV" );
            TrimLayerNode( ourGeoNoUv ); // drop the UV entry from the Layer node too
            var host2Objects = host2.Child( "Objects" );
            for ( var i = 0; i < host2Objects.Children.Count; i++ )
            {
                if ( host2Objects.Children[i].Name != "Geometry" )
                    continue;
                ourGeoNoUv.Properties[0] = host2Objects.Children[i].Properties[0];
                host2Objects.Children[i] = ourGeoNoUv;
                break;
            }
            Result.controlGeoNoUvBounds = await CompileControl( $"ctl_geonouv_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host2 ) );

            // (l) THEIR file + OUR Model node in place of their mesh model
            // (id preserved): isolates our Model node construction.
            var host3 = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourModel = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes )
                .Child( "Objects" ).Children.First(
                    c => c.Name == "Model" && (string)c.Properties[2] == "Mesh" );
            var host3Objects = host3.Child( "Objects" );
            for ( var i = 0; i < host3Objects.Children.Count; i++ )
            {
                if ( host3Objects.Children[i].Name != "Model" )
                    continue;
                ourModel.Properties[0] = host3Objects.Children[i].Properties[0];
                ourModel.Properties[1] = host3Objects.Children[i].Properties[1];
                host3Objects.Children[i] = ourModel;
                break;
            }
            Result.controlModelSwapBounds = await CompileControl( $"ctl_modelswap_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host3 ) );

            // (m) THEIR scaffold + OUR Objects and Connections wholesale: splits the
            // remaining suspects into header-sections vs object-graph.
            var host4 = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourDocument = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
            for ( var i = 0; i < host4.Children.Count; i++ )
            {
                if ( host4.Children[i].Name is "Objects" or "Connections" )
                    host4.Children[i] = ourDocument.Child( host4.Children[i].Name );
            }
            Result.controlHybridBounds = await CompileControl( $"ctl_hybrid_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host4 ) );

            // (n)-(p) scaffold bisect: swap ONE of our scaffold groups into their file.
            async Task<float> ScaffoldSwap( string tag, params string[] sections )
            {
                var target = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
                var source = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
                for ( var i = 0; i < target.Children.Count; i++ )
                {
                    if ( sections.Contains( target.Children[i].Name ) )
                        target.Children[i] = source.Child( target.Children[i].Name );
                }
                return await CompileControl( $"ctl_{tag}_{runTag}",
                    AutoRig.Formats.Fbx.FbxWriter.Write( target ) );
            }
            Result.controlScaffoldHeaderBounds = await ScaffoldSwap( "s1",
                "FBXHeaderExtension", "FileId", "CreationTime", "Creator" );
            Result.controlScaffoldSettingsBounds = await ScaffoldSwap( "s2", "GlobalSettings" );
            Result.controlScaffoldDocsBounds = await ScaffoldSwap( "s3",
                "Documents", "References", "Definitions", "Takes" );
        }
        catch ( Exception e )
        {
            Note( $"dataswap control failed: {e.Message}" );
        }

        Note( $"controls: original={Result.controlOriginalBounds:0.###} "
            + $"rewrite={Result.controlRewriteBounds:0.###} meshonly={Result.controlMeshOnlyBounds:0.###} "
            + $"docswap={Result.controlDocSwapBounds:0.###} defswap={Result.controlDefsSwapBounds:0.###} "
            + $"geoswap={Result.controlGeoSwapBounds:0.###} strip={Result.controlStripBounds:0.###} "
            + $"dataswap={Result.controlDataSwapBounds:0.###} nouv={Result.controlNoUvBounds:0.###} "
            + $"rename={Result.controlRenameBounds:0.###} geonouv={Result.controlGeoNoUvBounds:0.###} "
            + $"modelswap={Result.controlModelSwapBounds:0.###} hybrid={Result.controlHybridBounds:0.###} "
            + $"s1={Result.controlScaffoldHeaderBounds:0.###} s2={Result.controlScaffoldSettingsBounds:0.###} "
            + $"s3={Result.controlScaffoldDocsBounds:0.###}" );
        Flush();
    }

    static void TrimLayerNode( AutoRig.Formats.Fbx.FbxNode geometry )
    {
        var layer = geometry.Children.FirstOrDefault( c => c.Name == "Layer" );
        layer?.Children.RemoveAll( c =>
        {
            if ( c.Name != "LayerElement" )
                return false;
            var type = c.Children.FirstOrDefault( t => t.Name == "Type" );
            var typeName = type?.Properties.Count > 0 ? type.Properties[0] as string : null;
            return typeName is not ("LayerElementNormal" or "LayerElementMaterial");
        } );
    }

    static byte[] SwapSection( AutoRig.Formats.Fbx.FbxNode document, string sectionName,
        AutoRig.Formats.Fbx.FbxNode replacement )
    {
        for ( var i = 0; i < document.Children.Count; i++ )
        {
            if ( document.Children[i].Name == sectionName )
            {
                document.Children[i] = replacement;
                break;
            }
        }
        return AutoRig.Formats.Fbx.FbxWriter.Write( document );
    }

    static AutoRig.Formats.Fbx.FbxNode BuildOurDocuments()
    {
        var documents = new AutoRig.Formats.Fbx.FbxNode( "Documents" );
        var count = new AutoRig.Formats.Fbx.FbxNode( "Count" );
        count.Properties.Add( 1 );
        documents.Children.Add( count );
        var document = new AutoRig.Formats.Fbx.FbxNode( "Document" );
        document.Properties.Add( 999999L );
        document.Properties.Add( "" );
        document.Properties.Add( "Scene" );
        var rootNode = new AutoRig.Formats.Fbx.FbxNode( "RootNode" );
        rootNode.Properties.Add( 0L );
        document.Children.Add( rootNode );
        documents.Children.Add( document );
        return documents;
    }

    static AutoRig.Formats.Fbx.FbxNode BuildMinimalDefinitions()
    {
        var definitions = new AutoRig.Formats.Fbx.FbxNode( "Definitions" );
        var version = new AutoRig.Formats.Fbx.FbxNode( "Version" );
        version.Properties.Add( 100 );
        definitions.Children.Add( version );
        var count = new AutoRig.Formats.Fbx.FbxNode( "Count" );
        count.Properties.Add( 4 );
        definitions.Children.Add( count );
        foreach ( var (typeName, typeCount) in new (string, int)[]
        {
            ("GlobalSettings", 1), ("Model", 2), ("Geometry", 2), ("Material", 2),
        } )
        {
            var objectType = new AutoRig.Formats.Fbx.FbxNode( "ObjectType" );
            objectType.Properties.Add( typeName );
            var typeCountNode = new AutoRig.Formats.Fbx.FbxNode( "Count" );
            typeCountNode.Properties.Add( typeCount );
            objectType.Children.Add( typeCountNode );
            definitions.Children.Add( objectType );
        }
        return definitions;
    }

    /// <summary>Writes an fbx + vmdl pair, compiles, returns the loaded model's bounds size.</summary>
    static async Task<float> CompileControl( string name, byte[] fbxBytes )
    {
        try
        {
            var gateDir = Path.Combine( Project.Current.GetAssetsPath(), "autorig_gate" );
            Directory.CreateDirectory( gateDir );
            var fbxPath = Path.Combine( gateDir, $"{name}.fbx" );
            var vmdlPath = Path.Combine( gateDir, $"{name}.vmdl" );
            File.WriteAllBytes( fbxPath, fbxBytes );
            File.WriteAllText( vmdlPath, AutoRig.Export.VmdlGenerator.Generate(
                $"autorig_gate/{name}.fbx", name ) );
            AssetSystem.RegisterFile( fbxPath );
            var asset = AssetSystem.RegisterFile( vmdlPath );
            if ( asset is null )
                return -1f;
            await Task.Yield();
            var model = Model.Load( asset.Path ); // compile-on-demand
            return model is null || model.IsError ? -2f : model.Bounds.Size.Length;
        }
        catch ( Exception e )
        {
            Log.Info( $"[autorig-gate] control '{name}' failed: {e.Message}" );
            return -3f;
        }
    }

    // ---- plumbing (donor M0Gate pattern) ----

    static async Task<bool> WaitUntil( Func<bool> condition, float timeoutSeconds )
    {
        var sw = Stopwatch.StartNew();
        while ( sw.Elapsed.TotalSeconds < timeoutSeconds )
        {
            bool ok = false;
            try { ok = condition(); }
            catch { /* not ready yet */ }
            if ( ok )
                return true;
            await Task.Delay( 250 );
        }
        return false;
    }

    static T TryGet<T>( Func<T> getter )
    {
        try { return getter(); }
        catch { return default; }
    }

    static void Note( string message )
    {
        Result.log.Add( $"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}" );
        Log.Info( $"[autorig-gate] {message}" );
    }

    static void Flush()
    {
        try
        {
            File.WriteAllText( _resultPath, JsonSerializer.Serialize( Result,
                new JsonSerializerOptions { WriteIndented = true } ) );
        }
        catch
        {
            // never let result IO take the editor down
        }
    }

    class GateResult
    {
        public bool engineBooted { get; set; }
        public bool assetSystemReady { get; set; }
        public string projectPath { get; set; }
        public bool rigged { get; set; }
        public string solver { get; set; }
        public int rigJointCount { get; set; }
        public bool filesWritten { get; set; }
        public bool assetRegistered { get; set; }
        public bool vmdlCompiled { get; set; }
        public bool modelLoads { get; set; }
        public int boneCount { get; set; }
        public float meshBoundsSize { get; set; }
        public float controlOriginalBounds { get; set; }
        public float controlRewriteBounds { get; set; }
        public float controlMeshOnlyBounds { get; set; }
        public float controlDocSwapBounds { get; set; }
        public float controlDefsSwapBounds { get; set; }
        public float controlGeoSwapBounds { get; set; }
        public float controlStripBounds { get; set; }
        public float controlDataSwapBounds { get; set; }
        public float controlNoUvBounds { get; set; }
        public float controlRenameBounds { get; set; }
        public float controlGeoNoUvBounds { get; set; }
        public float controlModelSwapBounds { get; set; }
        public float controlHybridBounds { get; set; }
        public float controlScaffoldHeaderBounds { get; set; }
        public float controlScaffoldSettingsBounds { get; set; }
        public float controlScaffoldDocsBounds { get; set; }
        public bool refusedWrongProject { get; set; }
        public bool completed { get; set; }
        public bool passed { get; set; }
        public System.Collections.Generic.List<string> log { get; set; } = new();
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using AutoRig.Mesh;
using AutoRig.Rig;
using Editor;
using Sandbox;
using VecN = System.Numerics.Vector3;
using QuatN = System.Numerics.Quaternion;

namespace AutoRig.Editor;

/// <summary>
/// Pre-compile rig preview: the source mesh as a capped wireframe and the generated
/// skeleton as stick bones with joint markers, in an orbitable editor scene (same
/// idiom as humanoid-retargeter's PreviewWidget). The wiggle test rotates each joint
/// ±15° in sequence and CPU-skins the wireframe so a novice can SEE whether the rig
/// deforms sensibly before exporting.
/// </summary>
public sealed class RigPreviewWidget : SceneRenderingWidget
{
    const int MaxWireSegments = 9000;
    const int MaxSkinnedVertices = 150_000;
    const float WiggleDegrees = 15f;
    const float SecondsPerJoint = 0.9f;

    SceneLineObject _wire;
    SceneLineObject _bones;

    RigMesh _mesh;
    RigResult _rig;

    // Precomputed FK/skin state.
    VecN[] _bindWorld;          // joint bind positions
    VecN[] _posedWorld;         // joint posed positions (wiggle)
    QuatN[] _poseRotation;      // per-joint world rotation during wiggle
    int[] _wireVertexIndices;   // mesh vertex ids used by the capped wireframe, unique
    Vector3[] _wirePositions;   // posed positions for those vertices
    Dictionary<int, int> _wireSlotOf;
    (int A, int B)[] _wireSegments;

    float _yaw = 35f;
    float _zoom = 1f;
    int _wiggleFrame;
    Vector2 _lastMouse;
    float _wiggleTime = -1f;    // < 0 = not wiggling
    bool _skinnedWiggle;

    // ComputeBounds is O(all vertices); it was being called every frame from
    // both UpdateCamera and RedrawWire, which pegged a core with a big mesh in
    // the preview. Cache the extent + center once per SetRig instead.
    float _boundsLength = 1f;
    Vector3 _boundsCenter;      // scene-space
    int _dragJoint = -1;        // joint being dragged (move/deform preview), or -1
    bool _dragMoved;            // this gesture actually moved the joint
    bool _posedActive;          // the live pose diverges from the bind pose

    /// <summary>Fired once, at the first movement of a joint-drag, BEFORE the
    /// pose changes - so the dialog can snapshot for undo.</summary>
    public Action BeforeJointMove { get; set; }

    /// <summary>Ctrl+Z inside the viewport (the widget usually holds focus after
    /// a click/drag) - the dialog runs its undo.</summary>
    public Action UndoRequested { get; set; }

    protected override void OnKeyPress( KeyEvent e )
    {
        if ( e.Key == KeyCode.Z && e.HasCtrl )
        {
            UndoRequested?.Invoke();
            return;
        }
        base.OnKeyPress( e );
    }

    /// <summary>The currently highlighted joint name ("" when none).</summary>
    public string SelectedJoint => _highlight;

    /// <summary>Current live joint positions (mesh space), for undo snapshots.</summary>
    public VecN[] GetPose() => _posedWorld is null ? null : (VecN[])_posedWorld.Clone();

    /// <summary>Restores a joint pose captured by <see cref="GetPose"/>.</summary>
    public void SetPose( VecN[] pose )
    {
        if ( pose is null || _posedWorld is null || pose.Length != _posedWorld.Length )
            return;
        Array.Copy( pose, _posedWorld, pose.Length );
        _posedActive = false;
        for ( var i = 0; i < _posedWorld.Length && !_posedActive; i++ )
            if ( _bindWorld is not null && _posedWorld[i] != _bindWorld[i] )
                _posedActive = true;
        if ( _skinnedWiggle )
            RebuildSolid( posed: _posedActive );
        RebuildSkeletonGeometry();
    }

    /// <summary>True while the wiggle test runs.</summary>
    public bool Wiggling => _wiggleTime >= 0f;

    /// <summary>Name of the joint currently wiggling ("" when idle).</summary>
    public string WigglingJoint { get; private set; } = "";

    SceneObject _solid;

    public RigPreviewWidget( Widget parent ) : base( parent )
    {
        MinimumSize = new Vector2( 320, 320 );
        MouseTracking = true;

        Scene = Scene.CreateEditorScene();
        using ( Scene.Push() )
        {
            Camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
            Camera.BackgroundColor = Theme.ControlBackground;
            Camera.ZNear = 0.01f;
            Camera.ZFar = 8192f;
            Camera.FieldOfView = 45f;
            Camera.Enabled = true;
        }

        // Same lighting rig as humanoid-retargeter's preview: a warm key and a
        // cooler fill, no shadows (lines don't cast; the solid mesh reads cleanly).
        var world = Scene.SceneWorld;
        new ScenePointLight( world, new Vector3( 120, 100, 120 ), 600, Color.White * 3.5f ).ShadowsEnabled = false;
        new ScenePointLight( world, new Vector3( -120, -100, 90 ), 600, Color.White * 2.0f ).ShadowsEnabled = false;

        _wire = new SceneLineObject( world ) { Opaque = false, Lighting = false };
        // No overlay-layer tricks: that pass is not rendered by this widget's
        // camera (bones vanished entirely). Rigged view hides the solid instead,
        // so the skeleton inside the translucent wireframe is always visible.
        _bones = new SceneLineObject( world ) { Opaque = false, Lighting = false };
    }

    /// <summary>Builds a lit solid model from the raw mesh (the wireframe alone was
    /// nearly invisible: fixed-width translucent lines, unlit scene).</summary>
    /// <param name="posed">CPU-skin against the current wiggle pose (the solid IS
    /// the model the user watches - the line wireframe never renders here).</param>
    void RebuildSolid( bool posed = false )
    {
        _solid?.Delete();
        _solid = null;
        if ( _mesh is null || _mesh.TriangleCount == 0 )
            return;

        try
        {
            var material = TexturedMaterial()
                ?? Material.Load( "materials/dev/reflectivity_30.vmat" )
                ?? Material.Load( "materials/default/white.vmat" );
            var sceneMesh = new Sandbox.Mesh( material );
            var vertices = new List<SimpleVertex>( _mesh.Triangles.Length );
            for ( var t = 0; t < _mesh.TriangleCount; t++ )
            {
                for ( var k = 0; k < 3; k++ )
                {
                    var v = _mesh.Triangles[t * 3 + k];
                    var p = posed && _rig is not null ? SkinVertex( v ) : _mesh.Positions[v];
                    var n = v < _mesh.Normals.Length ? _mesh.Normals[v] : System.Numerics.Vector3.UnitZ;
                    var uv = v < _mesh.Uvs.Length ? _mesh.Uvs[v] : default;
                    vertices.Add( new SimpleVertex(
                        new Vector3( p.X, p.Y, p.Z ),
                        new Vector3( n.X, n.Y, n.Z ),
                        Vector3.Zero,
                        new Vector2( uv.X, uv.Y ) ) );
                }
            }
            sceneMesh.CreateVertexBuffer( vertices.Count, SimpleVertex.Layout, vertices );
            var model = Model.Builder.AddMesh( sceneMesh ).Create();
            _solid = new SceneObject( Scene.SceneWorld, model,
                new Transform( Vector3.Zero, Rotation.FromAxis( new Vector3( 1, 0, 0 ), 90f ) ) )
            {
                ColorTint = new Color( 0.62f, 0.66f, 0.72f ),
            };
            if ( _rig is not null )
            {
                // Ghost immediately (per-frame rebuilds must not flicker opaque).
                _solid.ColorTint = new Color( 0.62f, 0.66f, 0.72f, 0.3f );
                _solid.Flags.IsTranslucent = true;
                _solid.Flags.IsOpaque = false;
            }
        }
        catch ( Exception )
        {
            _solid?.Delete();
            _solid = null;   // wireframe fallback still draws below
        }
    }

    Material _texturedMaterial;
    bool _texturedTried;

    /// <summary>The mesh's base-color image (glTF/FBX embedded texture) as a
    /// preview material, decoded once. Null when absent/undecodable - the flat
    /// dev material stands in.</summary>
    Material TexturedMaterial()
    {
        if ( _texturedTried )
            return _texturedMaterial;
        _texturedTried = true;
        var image = _mesh?.Materials?.FirstOrDefault( m => m.BaseColorImage is not null )
            ?.BaseColorImage;
        if ( image is null )
            return null;
        try
        {
            var bitmap = Bitmap.CreateFromBytes( image );
            if ( bitmap is null )
                return null;
            var texture = bitmap.ToTexture();
            var material = Material.Create( $"autorig_preview_{GetHashCode()}", "simple" );
            material.Set( "Color", texture );
            _texturedMaterial = material;
            return material;
        }
        catch ( Exception )
        {
            return null;
        }
    }

    /// <summary>Installs the mesh + rig to display (null rig = mesh only).</summary>
    public void SetRig( RigMesh mesh, RigResult rig )
    {
        _mesh = mesh;
        _rig = rig;
        _wiggleTime = -1f;
        WigglingJoint = "";

        if ( mesh is null )
        {
            _wire.Clear();
            _bones.Clear();
            _solid?.Delete();
            _solid = null;
            _skeletonObject?.Delete();
            _skeletonObject = null;
            _boneObject?.Delete();
            _boneObject = null;
            _highlightObject?.Delete();
            _highlightObject = null;
            return;
        }

        var meshBounds = mesh.ComputeBounds();
        _boundsLength = MathF.Max( meshBounds.Size.Length(), 1e-3f );
        _boundsCenter = ToVector3( meshBounds.Center );
        RebuildSolid();
        BuildWireTopology();
        if ( rig is not null )
        {
            _bindWorld = new VecN[rig.Skeleton.Joints.Count];
            _posedWorld = new VecN[rig.Skeleton.Joints.Count];
            _poseRotation = new QuatN[rig.Skeleton.Joints.Count];
            for ( var i = 0; i < rig.Skeleton.Joints.Count; i++ )
            {
                _bindWorld[i] = rig.Skeleton.Joints[i].Position;
                _posedWorld[i] = _bindWorld[i];
                _poseRotation[i] = QuatN.Identity;
            }
            // No vertex cap: a static model during the wiggle test defeats the test.
            // Heavy meshes throttle the rebuild rate instead (see PreFrame).
            _skinnedWiggle = rig.Weights is not null;
        }

        RedrawWire( bindPose: true );
        RedrawBones();
        RebuildSkeletonGeometry();
        UpdateLineBounds();
    }

    /// <summary>SceneLineObjects keep tiny default bounds and get frustum-culled -
    /// the reason neither the wireframe nor the skeleton ever showed. Give both the
    /// mesh's (scene-space) bounds, padded.</summary>
    void UpdateLineBounds()
    {
        if ( _mesh is null )
            return;
        var bounds = _mesh.ComputeBounds();
        var a = ToVector3( bounds.Min );
        var b = ToVector3( bounds.Max );
        var box = new BBox( Vector3.Min( a, b ), Vector3.Max( a, b ) );
        box = box.Grow( bounds.Size.Length() * 0.5f + 1f );
        _wire.Bounds = box;
        _bones.Bounds = box;
    }

    /// <summary>Starts the wiggle test (each joint ±15° in sequence; root skipped).</summary>
    public void StartWiggle()
    {
        if ( _rig is null )
            return;
        _wiggleTime = 0f;
        _wiggleFrame = 0;
        Log.Info( $"[auto-rig] wiggle: {_mesh?.Positions.Length ?? 0} verts, "
            + $"skinned deform = {_skinnedWiggle}" );
    }

    /// <summary>Stops the wiggle test and returns to the bind pose.</summary>
    public void StopWiggle()
    {
        _wiggleTime = -1f;
        WigglingJoint = "";
        if ( _rig is not null )
        {
            for ( var i = 0; i < _poseRotation.Length; i++ )
            {
                _poseRotation[i] = QuatN.Identity;
                _posedWorld[i] = _bindWorld[i];
            }
        }
        RedrawWire( bindPose: true );
        RedrawBones();
        RebuildSkeletonGeometry();
        RebuildSolid();   // back to the bind pose
    }

    protected override void PreFrame()
    {
        Scene.EditorTick( RealTime.Now, RealTime.Delta );
        UpdateCamera();

        // The wire/skeleton are SceneLineObjects - they must be re-submitted each
        // frame or they vanish. Submission is cheap (bounded by the wire cap); it
        // was the two O(all-verts) ComputeBounds() calls per frame (here + in
        // UpdateCamera) that pegged a core with a preview open - both now cached.
        if ( _mesh is not null && _wiggleTime < 0f )
        {
            RedrawWire( bindPose: !_posedActive );
            RedrawBones();
        }

        if ( _wiggleTime >= 0f && _rig is not null )
        {
            _wiggleTime += RealTime.Delta;
            var jointCount = _rig.Skeleton.Joints.Count;
            var wigglable = Math.Max( 1, jointCount - 1 ); // skip the root
            var slot = (int)(_wiggleTime / SecondsPerJoint);
            if ( slot >= wigglable )
            {
                StopWiggle();
                return;
            }
            var joint = slot + 1; // joints are parent-before-child; 0 is root
            WigglingJoint = _rig.Skeleton.Joints[joint].Name;

            var phase = (_wiggleTime % SecondsPerJoint) / SecondsPerJoint; // 0..1
            var angle = MathF.Sin( phase * MathF.Tau ) * WiggleDegrees * (MathF.PI / 180f);
            ApplyWigglePose( joint, angle );
            RedrawWire( bindPose: false );
            RedrawBones();
            RebuildSkeletonGeometry();
            // The solid IS what the user watches - deform it with the pose. Heavy
            // meshes rebuild every 3rd frame (choppier but MOVING).
            _wiggleFrame++;
            if ( _skinnedWiggle
                && (_mesh.Positions.Length <= 80_000 || _wiggleFrame % 3 == 0) )
                RebuildSolid( posed: true );
        }
    }

    /// <summary>FK pose with a single joint rotated about its hinge axis (or X).</summary>
    void ApplyWigglePose( int wiggleJoint, float angle )
    {
        var joints = _rig.Skeleton.Joints;
        var axis = joints[wiggleJoint].HingeAxis;
        var axisN = axis == VecN.Zero ? VecN.UnitX : VecN.Normalize( axis );
        var spin = QuatN.CreateFromAxisAngle( axisN, angle );

        for ( var i = 0; i < joints.Count; i++ )
        {
            var parent = joints[i].Parent;
            if ( parent < 0 )
            {
                _poseRotation[i] = i == wiggleJoint ? spin : QuatN.Identity;
                _posedWorld[i] = _bindWorld[i];
                continue;
            }
            var localOffset = _bindWorld[i] - _bindWorld[parent];
            var parentRotation = _poseRotation[parent];
            _posedWorld[i] = _posedWorld[parent] + VecN.Transform( localOffset, parentRotation );
            _poseRotation[i] = i == wiggleJoint
                ? parentRotation * spin
                : parentRotation;
        }
    }

    /// <summary>Collects a capped set of triangle edges (stride over triangles) plus the
    /// unique vertex list they reference, so wiggle re-skins only what is drawn.</summary>
    void BuildWireTopology()
    {
        var segments = new List<(int A, int B)>();
        _wireSlotOf = new Dictionary<int, int>();
        var vertexIds = new List<int>();

        int SlotOf( int vertex )
        {
            if ( !_wireSlotOf.TryGetValue( vertex, out var slot ) )
            {
                slot = vertexIds.Count;
                vertexIds.Add( vertex );
                _wireSlotOf.Add( vertex, slot );
            }
            return slot;
        }

        var stride = Math.Max( 1, _mesh.TriangleCount * 3 / MaxWireSegments );
        for ( var t = 0; t < _mesh.TriangleCount; t += stride )
        {
            var a = _mesh.Triangles[t * 3];
            var b = _mesh.Triangles[t * 3 + 1];
            var c = _mesh.Triangles[t * 3 + 2];
            segments.Add( (SlotOf( a ), SlotOf( b )) );
            segments.Add( (SlotOf( b ), SlotOf( c )) );
            segments.Add( (SlotOf( c ), SlotOf( a )) );
        }

        _wireSegments = segments.ToArray();
        _wireVertexIndices = vertexIds.ToArray();
        _wirePositions = new Vector3[_wireVertexIndices.Length];
    }

    void RedrawWire( bool bindPose )
    {
        if ( _mesh is null || _wireSegments is null )
            return;

        for ( var s = 0; s < _wireVertexIndices.Length; s++ )
        {
            var v = _wireVertexIndices[s];
            var p = (!bindPose && _skinnedWiggle && _rig is not null)
                ? SkinVertex( v )
                : _mesh.Positions[v];
            _wirePositions[s] = ToVector3( p );
        }

        // Width relative to the model (a fixed width is dust on big models, a blob
        // on small ones). During a skinned wiggle the wire is the star: brighter,
        // and the static solid hides so the deformation reads.
        var scale = _boundsLength;
        var width = scale * 0.0012f;
        // The solid model stays visible ALWAYS (an empty viewport is worse than an
        // occluded bone); wire is a subtle shell, brighter while wiggling.
        var color = bindPose
            ? new Color( 0.65f, 0.75f, 0.85f, 0.2f )
            : new Color( 0.55f, 0.85f, 1f, 0.85f );
        if ( _solid is not null )
        {
            _solid.RenderingEnabled = true;   // the solid is the model, always shown
            // With a rig: ghost the body so the skeleton reads through it.
            var ghosted = _rig is not null;
            _solid.ColorTint = ghosted
                ? new Color( 0.62f, 0.66f, 0.72f, 0.3f )
                : new Color( 0.62f, 0.66f, 0.72f, 1f );
            _solid.Flags.IsTranslucent = ghosted;
            _solid.Flags.IsOpaque = !ghosted;
        }

        _wire.Clear();
        foreach ( var (a, b) in _wireSegments )
        {
            _wire.StartLine();
            _wire.AddLinePoint( _wirePositions[a], color, width );
            _wire.AddLinePoint( _wirePositions[b], color, width );
            _wire.EndLine();
        }
    }

    /// <summary>Linear-blend skin of one vertex against the current wiggle pose.</summary>
    VecN SkinVertex( int v )
    {
        var bind = _mesh.Positions[v];
        var result = VecN.Zero;
        float total = 0;
        for ( var k = 0; k < 4; k++ )
        {
            var w = _rig.Weights.Weights[v * 4 + k];
            if ( w <= 0f )
                continue;
            var bone = _rig.Weights.BoneIndices[v * 4 + k];
            var local = bind - _bindWorld[bone];
            result += (_posedWorld[bone] + VecN.Transform( local, _poseRotation[bone] )) * w;
            total += w;
        }
        return total > 1e-4f ? result / total : bind;
    }

    SceneObject _skeletonObject;   // joint octahedra (red, the traditional idiom)
    SceneObject _boneObject;       // bone bipyramids (blue)
    SceneObject _highlightObject;
    string _highlight = "";

    /// <summary>Marks one joint (by name) with a bigger yellow marker - driven by
    /// the adjust panel's selection.</summary>
    public void SetHighlight( string jointName )
    {
        _highlight = jointName ?? "";
        RebuildSkeletonGeometry();
    }

    /// <summary>Raised when the user clicks a joint in the viewport (joint name),
    /// or clicks empty space (null - selection cleared).</summary>
    public Action<string> JointPicked { get; set; }

    /// <summary>The joint under the given widget-local position, or -1. Joints
    /// are projected through the same orbit camera the viewport renders with;
    /// the nearest projected joint within a forgiving radius wins.</summary>
    int PickJoint( Vector2 local )
    {
        if ( _rig is null || _posedWorld is null || !Camera.IsValid() )
            return -1;

        var forward = Camera.WorldRotation.Forward;
        var right = Camera.WorldRotation.Right;
        var up = Camera.WorldRotation.Up;
        var origin = Camera.WorldPosition;

        // Vertical-FOV pinhole projection. Even if the engine's FOV convention
        // differs slightly, nearest-projected-joint keeps picks on target.
        var tanHalf = MathF.Tan( MathX.DegreeToRadian( Camera.FieldOfView ) * 0.5f );
        var aspect = Width > 0 ? (float)Width / Math.Max( (float)Height, 1f ) : 1f;

        var best = -1;
        var bestDistance = float.MaxValue;
        for ( var i = 0; i < _posedWorld.Length; i++ )
        {
            var world = ToVector3( _posedWorld[i] );
            var toJoint = world - origin;
            var depth = Vector3.Dot( toJoint, forward );
            if ( depth <= 0.001f )
                continue;   // behind the camera
            var ndcX = Vector3.Dot( toJoint, right ) / (depth * tanHalf * aspect);
            var ndcY = Vector3.Dot( toJoint, up ) / (depth * tanHalf);
            var px = (ndcX * 0.5f + 0.5f) * Width;
            var py = (0.5f - ndcY * 0.5f) * Height;
            var d = new Vector2( px, py ).Distance( local );
            if ( d < bestDistance )
            {
                bestDistance = d;
                best = i;
            }
        }

        // Forgiving click target: ~4% of the viewport's smaller side, min 14px.
        var tolerance = MathF.Max( MathF.Min( (float)Width, (float)Height ) * 0.04f, 14f );
        return bestDistance <= tolerance ? best : -1;
    }

    /// <summary>Right-clicked a joint (name) - the dialog raises its context menu.</summary>
    public Action<string> JointContextRequested { get; set; }

    /// <summary>Re-applies the (edited) rig after a delete/rename, rebuilding all
    /// preview geometry and clearing any live move-pose.</summary>
    public void Reload( RigResult rig )
    {
        _posedActive = false;
        SetRig( _mesh, rig );
    }

    /// <summary>Snaps the live move-pose back to the rest skeleton.</summary>
    public void ResetPose()
    {
        if ( _rig is null || _bindWorld is null )
            return;
        for ( var i = 0; i < _posedWorld.Length; i++ )
        {
            _posedWorld[i] = _bindWorld[i];
            _poseRotation[i] = QuatN.Identity;
        }
        _posedActive = false;
        RebuildSolid();
        RebuildSkeletonGeometry();
    }

    protected override void OnMousePress( MouseEvent e )
    {
        base.OnMousePress( e );
        _lastMouse = e.LocalPosition;
        if ( _rig is null )
            return;

        var picked = PickJoint( e.LocalPosition );

        if ( e.RightMouseButton && picked >= 0 )
        {
            SetHighlight( _rig.Skeleton.Joints[picked].Name );
            JointPicked?.Invoke( _rig.Skeleton.Joints[picked].Name );
            JointContextRequested?.Invoke( _rig.Skeleton.Joints[picked].Name );
            return;
        }

        if ( e.LeftMouseButton )
        {
            if ( picked >= 0 )
            {
                SetHighlight( _rig.Skeleton.Joints[picked].Name );
                JointPicked?.Invoke( _rig.Skeleton.Joints[picked].Name );
                _dragJoint = picked;   // a left-drag from a joint MOVES it (deform preview)
                _dragMoved = false;
            }
            else
            {
                _dragJoint = -1;
                if ( _highlight.Length > 0 )
                {
                    SetHighlight( "" );
                    JointPicked?.Invoke( null );
                }
            }
        }
    }

    protected override void OnMouseReleased( MouseEvent e )
    {
        base.OnMouseReleased( e );
        _dragJoint = -1;
        _dragMoved = false;
    }

    /// <summary>Mesh-space delta from a scene-space delta (inverse of ToVector3).</summary>
    static VecN FromVector3( Vector3 v ) => new( v.x, v.z, -v.y );

    /// <summary>Translates a joint AND its whole subtree in the LIVE pose (not the
    /// rest skeleton), so the mesh deforms via the existing skin path - a
    /// translation analogue of the wiggle test. Not persisted: it is a
    /// "does this joint drive the right vertices?" check.</summary>
    void MoveJointSubtree( int root, VecN meshDelta )
    {
        if ( _rig is null || _posedWorld is null )
            return;
        if ( !_dragMoved )
        {
            BeforeJointMove?.Invoke();   // snapshot the pre-move pose for undo
            _dragMoved = true;
        }
        var joints = _rig.Skeleton.Joints;
        var stack = new Stack<int>();
        stack.Push( root );
        while ( stack.Count > 0 )
        {
            var j = stack.Pop();
            _posedWorld[j] += meshDelta;
            for ( var c = 0; c < joints.Count; c++ )
                if ( joints[c].Parent == j )
                    stack.Push( c );
        }
        _posedActive = true;
        if ( _skinnedWiggle )
            RebuildSolid( posed: true );
        RebuildSkeletonGeometry();
    }

    /// <summary>
    /// The skeleton as REAL geometry (SceneLineObject never renders in this widget;
    /// SceneObject+Model provably does): each bone an elongated bipyramid, each
    /// joint an octahedron, ivory-tinted, rebuilt from the current pose.
    /// </summary>
    void RebuildSkeletonGeometry()
    {
        _skeletonObject?.Delete();
        _skeletonObject = null;
        _boneObject?.Delete();
        _boneObject = null;
        _highlightObject?.Delete();
        _highlightObject = null;
        if ( _rig is null || _mesh is null )
            return;

        try
        {
            var joints = _rig.Skeleton.Joints;
            var scale = _boundsLength;
            var jointRadius = scale * 0.008f;
            var boneRadius = scale * 0.004f;

            var jointVertices = new List<SimpleVertex>();
            var boneVertices = new List<SimpleVertex>();
            var vertices = jointVertices;   // Triangle() writes into this target
            void Triangle( Vector3 a, Vector3 b, Vector3 c )
            {
                var normal = Vector3.Cross( b - a, c - a ).Normal;
                vertices.Add( new SimpleVertex( a, normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( b, normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( c, normal, Vector3.Zero, Vector2.Zero ) );
                // Backface too - bones must read from every side.
                vertices.Add( new SimpleVertex( a, -normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( c, -normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( b, -normal, Vector3.Zero, Vector2.Zero ) );
            }
            void Octahedron( Vector3 center, float radius )
            {
                foreach ( var sx in new[] { -1f, 1f } )
                    foreach ( var sy in new[] { -1f, 1f } )
                        foreach ( var sz in new[] { -1f, 1f } )
                            Triangle(
                                center + Vector3.Forward * radius * sx,
                                center + Vector3.Left * radius * sy,
                                center + Vector3.Up * radius * sz );
            }
            void Bone( Vector3 from, Vector3 to, float radius )
            {
                var direction = to - from;
                if ( direction.Length < 1e-6f )
                    return;
                var side = Vector3.Cross( direction.Normal, Vector3.Up );
                if ( side.Length < 1e-4f )
                    side = Vector3.Cross( direction.Normal, Vector3.Forward );
                var s1 = side.Normal * radius;
                var s2 = Vector3.Cross( direction.Normal, side.Normal ).Normal * radius;
                var hub = from + direction * 0.15f;
                foreach ( var (a, b) in new[] { (s1, s2), (s2, -s1), (-s1, -s2), (-s2, s1) } )
                {
                    Triangle( from, hub + a, hub + b );
                    Triangle( hub + a, to, hub + b );
                }
            }

            var highlighted = _highlight.Length > 0
                ? joints.FindIndex( j => j.Name == _highlight ) : -1;

            for ( var i = 0; i < joints.Count; i++ )
            {
                var pos = ToVector3( _posedWorld[i] );
                if ( i != highlighted )   // the selected joint is drawn GREEN below
                {
                    vertices = jointVertices;
                    Octahedron( pos, jointRadius );
                }
                if ( joints[i].Parent >= 0 )
                {
                    vertices = boneVertices;
                    Bone( ToVector3( _posedWorld[joints[i].Parent] ), pos, boneRadius );
                }
            }

            var material = Material.Load( "materials/dev/reflectivity_30.vmat" )
                ?? Material.Load( "materials/default/white.vmat" );

            // Traditional rig colors: RED joints, BLUE bones (user request).
            if ( jointVertices.Count > 0 )
            {
                var jointMesh = new Sandbox.Mesh( material );
                jointMesh.CreateVertexBuffer( jointVertices.Count, SimpleVertex.Layout, jointVertices );
                _skeletonObject = new SceneObject(
                    Scene.SceneWorld, Model.Builder.AddMesh( jointMesh ).Create(), Transform.Zero )
                {
                    ColorTint = new Color( 0.92f, 0.18f, 0.15f ),
                };
            }
            if ( boneVertices.Count > 0 )
            {
                var boneMesh = new Sandbox.Mesh( material );
                boneMesh.CreateVertexBuffer( boneVertices.Count, SimpleVertex.Layout, boneVertices );
                _boneObject = new SceneObject(
                    Scene.SceneWorld, Model.Builder.AddMesh( boneMesh ).Create(), Transform.Zero )
                {
                    ColorTint = new Color( 0.20f, 0.45f, 0.95f ),
                };
            }

            // The selected joint IS the marker: its own octahedron drawn in
            // fluorescent green (slightly enlarged so it pops) instead of the red
            // one - a highlight of the joint, not a second shape covering it.
            if ( highlighted >= 0 )
            {
                vertices = new List<SimpleVertex>();
                Octahedron( ToVector3( _posedWorld[highlighted] ), jointRadius * 1.35f );
                var highlightMesh = new Sandbox.Mesh( material );
                highlightMesh.CreateVertexBuffer( vertices.Count, SimpleVertex.Layout, vertices );
                _highlightObject = new SceneObject( Scene.SceneWorld,
                    Model.Builder.AddMesh( highlightMesh ).Create(), Transform.Zero )
                {
                    ColorTint = new Color( 0.30f, 1f, 0.15f ),   // fluorescent green
                };
            }
        }
        catch ( Exception )
        {
            _skeletonObject?.Delete();
            _skeletonObject = null;
            _boneObject?.Delete();
            _boneObject = null;
        }
    }

    void RedrawBones()
    {
        _bones.Clear();
        if ( _rig is null )
            return;

        var joints = _rig.Skeleton.Joints;
        // Soft bone tones (no fluorescent green): warm ivory bones, blue joints.
        var boneColor = new Color( 0.93f, 0.82f, 0.6f, 0.95f );
        var jointColor = Theme.Blue.WithAlpha( 0.95f );
        var activeColor = Theme.Yellow;
        var scale = MathF.Max( _boundsLength, 1f );
        var tick = scale * 0.006f;

        for ( var i = 0; i < joints.Count; i++ )
        {
            var pos = ToVector3( _posedWorld[i] );
            var parent = joints[i].Parent;
            if ( parent >= 0 )
            {
                _bones.StartLine();
                _bones.AddLinePoint( ToVector3( _posedWorld[parent] ), boneColor, tick * 0.9f );
                _bones.AddLinePoint( pos, boneColor, tick * 0.9f );
                _bones.EndLine();
            }

            // Joint = a 3-axis star so it reads as a node from any angle.
            var isActive = joints[i].Name == WigglingJoint;
            var markerColor = isActive ? activeColor : jointColor;
            foreach ( var axis in new[] { Vector3.Up, Vector3.Left, Vector3.Forward } )
            {
                _bones.StartLine();
                _bones.AddLinePoint( pos - axis * tick * 1.5f, markerColor, tick * 1.6f );
                _bones.AddLinePoint( pos + axis * tick * 1.5f, markerColor, tick * 1.6f );
                _bones.EndLine();
            }
        }
    }

    /// <summary>Mesh space (Y-up, as our whole pipeline assumes) → s&box scene
    /// space (Z-up): rotate +90° about X. Without this everything lies sideways.</summary>
    static Vector3 ToVector3( VecN v ) => new( v.X, -v.Z, v.Y );

    void UpdateCamera()
    {
        if ( !Camera.IsValid() || _mesh is null )
            return;

        // Cached (ComputeBounds is O(verts) and this runs every frame).
        var center = _boundsCenter;
        var radius = MathF.Max( _boundsLength * 0.5f, 1f );
        var distance = MathX.SphereCameraDistance( radius, Camera.FieldOfView ) * 1.1f * _zoom;

        var yawRad = MathX.DegreeToRadian( _yaw );
        var dir = new Vector3( MathF.Cos( yawRad ), MathF.Sin( yawRad ), 0.35f ).Normal;
        Camera.WorldPosition = center + dir * distance;
        Camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );
    }

    protected override void OnWheel( WheelEvent e )
    {
        base.OnWheel( e );
        _zoom = Math.Clamp( _zoom * (e.Delta > 0 ? 0.88f : 1.14f), 0.15f, 6f );
        e.Accept();
    }

    protected override void OnMouseMove( MouseEvent e )
    {
        base.OnMouseMove( e );
        var delta = e.LocalPosition - _lastMouse;
        _lastMouse = e.LocalPosition;
        if ( (e.ButtonState & MouseButtons.Left) == 0 )
            return;

        // Dragging a selected joint moves it in the camera-facing plane and the
        // mesh follows; dragging empty space orbits.
        if ( _dragJoint >= 0 && _posedWorld is not null && Camera.IsValid() )
        {
            var jointScene = ToVector3( _posedWorld[_dragJoint] );
            var depth = Vector3.Dot( jointScene - Camera.WorldPosition, Camera.WorldRotation.Forward );
            var tanHalf = MathF.Tan( MathX.DegreeToRadian( Camera.FieldOfView ) * 0.5f );
            var worldPerPixel = 2f * tanHalf * MathF.Max( depth, 0.01f ) / MathF.Max( (float)Height, 1f );
            var sceneDelta = Camera.WorldRotation.Right * (delta.x * worldPerPixel)
                + Camera.WorldRotation.Up * (-delta.y * worldPerPixel);
            MoveJointSubtree( _dragJoint, FromVector3( sceneDelta ) );
            return;
        }

        _yaw -= delta.x * 0.4f;
    }

    public override void OnDestroyed()
    {
        base.OnDestroyed();
        _solid?.Delete();
        _solid = null;
        _skeletonObject?.Delete();
        _skeletonObject = null;
        _boneObject?.Delete();
        _boneObject = null;
        _highlightObject?.Delete();
        _highlightObject = null;
        Scene?.Destroy();
        Scene = null;
    }
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using AutoRig.Vast;

namespace Editor.AutoRig.Vast;

/// <summary>
/// Thin transport for the vast.ai REST API (console.vast.ai, Bearer key). Each
/// call names its own API version because the migration is selective: the
/// /instances family is v1 (v0 returns 410 Gone), while offer search and rental
/// creation are still v0. All request/response shapes live in whitelist-safe
/// Code/AutoRig/Vast so this class stays dumb and the logic stays unit-tested.
/// </summary>
public sealed class VastClient : IDisposable
{
    public const string BaseUrl = "https://console.vast.ai/";
    readonly HttpClient _http;

    public VastClient( string apiKey, HttpMessageHandler handler = null )
    {
        if ( string.IsNullOrWhiteSpace( apiKey ) )
            throw new ArgumentException( "vast.ai API key is required.", nameof( apiKey ) );
        _http = handler is null ? new HttpClient() : new HttpClient( handler );
        _http.BaseAddress = new Uri( BaseUrl );
        _http.Timeout = TimeSpan.FromSeconds( 60 );
        _http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue( "Bearer", apiKey.Trim() );
    }

    public async Task<List<VastOffer>> SearchOffers( int minGpuRamMb, int minDiskGb )
    {
        var body = new StringContent(
            VastOffers.BuildSearchBody( minGpuRamMb, minDiskGb ),
            Encoding.UTF8, "application/json" );
        // Offer search is v0 /search/asks/ (PUT). v0 /bundles/ is gone.
        var response = await _http.PutAsync( "api/v0/search/asks/", body );
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai offer search failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastOffers.ParseOffers( json );
    }

    /// <summary>Rents the offer. The caller MUST persist the returned id to the
    /// ownership ledger before doing anything else with it.</summary>
    public async Task<long> CreateInstance( long offerId, string image, string onstart, int diskGb )
    {
        var payload = System.Text.Json.JsonSerializer.Serialize( new Dictionary<string, object>
        {
            ["client_id"] = "me",
            ["image"] = image,
            ["disk"] = diskGb,
            ["onstart"] = onstart,
            ["runtype"] = "ssh",
        } );
        // Rental creation is v0 /asks/{offerId}/ (PUT) - not an /instances path.
        var response = await _http.PutAsync( $"api/v0/asks/{offerId}/",
            new StringContent( payload, Encoding.UTF8, "application/json" ) );
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai rental failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastInstances.ParseCreateResponse( json );
    }

    /// <summary>Lists the user's current instances (read-only) so a rig can be
    /// OFFLOADED to one already running. This only enumerates for display - the
    /// destroy path still takes its id from the ledger alone, so listing here
    /// can never lead to touching an instance we did not create.</summary>
    public async Task<List<VastInstances.InstanceSummary>> ListInstances()
    {
        var response = await _http.GetAsync( "api/v1/instances/" );
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai instance list failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastInstances.ParseInstanceList( json );
    }

    /// <summary>Null when the instance no longer exists (verified-destroy signal).</summary>
    public async Task<VastInstances.InstanceState> GetInstance( long id )
    {
        // Instances are v1 (v0 /instances/ returns 410 Gone). owner=me matches
        // the official CLI - harmless and accepted by v1.
        var response = await _http.GetAsync( $"api/v1/instances/{id}/?owner=me" );
        if ( response.StatusCode == System.Net.HttpStatusCode.NotFound )
            return null;
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai instance query failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastInstances.ParseInstance( json );
    }

    /// <summary>DELETE exactly this id - never enumerates, never touches others.
    /// Verified by re-GET and retried; returns true only when the instance is
    /// confirmed gone.</summary>
    public async Task<bool> DestroyVerified( long id, int attempts = 5 )
    {
        for ( var attempt = 0; attempt < attempts; attempt++ )
        {
            try
            {
                // Instances are v1; the official CLI sends an empty JSON body.
                using var request = new HttpRequestMessage( HttpMethod.Delete, $"api/v1/instances/{id}/" )
                {
                    Content = new StringContent( "{}", Encoding.UTF8, "application/json" ),
                };
                await _http.SendAsync( request );
                await Task.Delay( TimeSpan.FromSeconds( 3 + attempt * 3 ) );
                if ( await GetInstance( id ) is null )
                    return true;
            }
            catch
            {
                await Task.Delay( TimeSpan.FromSeconds( 5 ) );
            }
        }
        return await GetInstance( id ) is null;
    }

    static string Truncate( string s )
        => s is null ? "" : s.Length > 300 ? s[..300] : s;

    public void Dispose() => _http.Dispose();
}
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using AutoRig.Analyze;
using AutoRig.Rig;
using AutoRig.Vast;
using global::Editor;
using Sandbox;

namespace Editor.AutoRig.Vast;

/// <summary>
/// One cloud rig, cradle to grave: rent → ledger → boot → upload → rig →
/// download → DESTROY (verified, in finally). The ledger records exactly the
/// instance we created; if verified destruction fails the ledger survives so
/// the stale-rental prompt can finish the job next time - the user's other
/// instances are never enumerated, never touched.
/// </summary>
public sealed class VastRigSession
{
    public static string LedgerPath => Path.Combine(
        Project.Current?.GetAssetsPath() ?? ".", "autorig_dl", "vast_rented.json" );

    /// <summary>Adjustable via the settings dialog (VastSettings.Apply on load).</summary>
    public static TimeSpan BootTimeout { get; set; } = TimeSpan.FromMinutes( 12 );
    public static TimeSpan RigTimeout { get; set; } = TimeSpan.FromMinutes( 30 );

    readonly VastClient _client;
    readonly Action<string> _progress;

    public VastRigSession( VastClient client, Action<string> progress )
    {
        _client = client;
        _progress = progress ?? (_ => { });
    }

    /// <summary>Null when no rental is outstanding.</summary>
    public static VastRental StaleRental()
        => File.Exists( LedgerPath ) ? VastRental.Parse( File.ReadAllText( LedgerPath ) ) : null;

    /// <summary>Destroys a stale rental (verified); clears the ledger on success.</summary>
    public async Task<bool> DestroyStale( VastRental rental )
    {
        var gone = await _client.DestroyVerified( rental.InstanceId );
        if ( gone )
            ClearLedger();
        return gone;
    }

    public async Task<RigResult> RigAsync(
        AnalysisResult analysis, string modelId, string modelTitle, VastOffer offer )
    {
        var objBytes = ObjWriter.Write( analysis.Mesh );

        _progress( $"renting {offer.GpuName} (${offer.DollarsPerHour:0.00}/hr)…" );
        var instanceId = await _client.CreateInstance(
            offer.Id, VastProtocol.DockerImage,
            VastProtocol.BuildOnStart( modelId ), VastProtocol.DiskGb );

        // Ownership ledger BEFORE anything else can fail.
        Directory.CreateDirectory( Path.GetDirectoryName( LedgerPath )! );
        File.WriteAllText( LedgerPath, new VastRental
        {
            InstanceId = instanceId,
            OfferId = offer.Id,
            CreatedUtc = DateTime.UtcNow.ToString( "o" ),
            Label = modelTitle,
        }.Serialize() );

        try
        {
            var endpoint = await WaitForBoot( instanceId );
            using var http = new HttpClient { Timeout = TimeSpan.FromMinutes( 5 ) };

            _progress( "uploading mesh…" );
            var upload = await http.PostAsync(
                $"{endpoint}/rig?model={Uri.EscapeDataString( modelId )}",
                new ByteArrayContent( objBytes ) );
            upload.EnsureSuccessStatusCode();

            _progress( "rigging remotely…" );
            var deadline = DateTime.UtcNow + RigTimeout;
            while ( true )
            {
                if ( DateTime.UtcNow > deadline )
                    throw new TimeoutException( "remote rig timed out." );
                await Task.Delay( TimeSpan.FromSeconds( 10 ) );
                var status = await http.GetStringAsync( $"{endpoint}/status" );
                if ( status.Contains( "\"done\"" ) || status.Contains( "\"error\"" ) )
                    break;
            }

            _progress( "downloading result…" );
            var resultJson = await http.GetStringAsync( $"{endpoint}/result" );
            var remote = VastProtocol.ParseResult( resultJson );   // throws w/ remote log on error
            return VastProtocol.ToRigResult( analysis, remote, modelTitle );
        }
        finally
        {
            _progress( $"destroying instance {instanceId}…" );
            var destroyed = await _client.DestroyVerified( instanceId );
            if ( destroyed )
            {
                ClearLedger();
                _progress( $"instance {instanceId} DESTROYED (verified)." );
            }
            else
            {
                _progress( $"WARNING: could not verify destruction of instance {instanceId} - "
                    + "it stays in the ledger; you will be prompted to retry. "
                    + "Check console.vast.ai to avoid charges." );
            }
        }
    }

    /// <summary>Offloads a rig onto an instance the user ALREADY has running:
    /// upload → rig → download. By default it NEVER rents, NEVER destroys, and
    /// NEVER writes the ledger - the box keeps running afterwards (the point of
    /// offload is to reuse a machine that already has the model installed).
    /// When <paramref name="destroyAfter"/> is set the caller has explicitly
    /// opted in to destroying THIS instance (verified) once the rig is done -
    /// still only ever the exact id passed here.</summary>
    public async Task<RigResult> RigOnExisting(
        AnalysisResult analysis, string modelId, string modelTitle, long instanceId,
        bool destroyAfter = false )
    {
        var objBytes = ObjWriter.Write( analysis.Mesh );
        try
        {
            _progress( $"connecting to instance {instanceId}…" );
            var endpoint = await WaitForBoot( instanceId );   // health-poll, reused as-is
            using var http = new HttpClient { Timeout = TimeSpan.FromMinutes( 5 ) };

            // The box auto-provisions the model if it isn't installed yet.
            _progress( "uploading mesh…" );
            var upload = await http.PostAsync(
                $"{endpoint}/rig?model={Uri.EscapeDataString( modelId )}",
                new ByteArrayContent( objBytes ) );
            upload.EnsureSuccessStatusCode();

            _progress( "rigging on your instance…" );
            var deadline = DateTime.UtcNow + RigTimeout;
            while ( true )
            {
                if ( DateTime.UtcNow > deadline )
                    throw new TimeoutException( "remote rig timed out." );
                await Task.Delay( TimeSpan.FromSeconds( 10 ) );
                var status = await http.GetStringAsync( $"{endpoint}/status" );
                if ( status.Contains( "\"done\"" ) || status.Contains( "\"error\"" ) )
                    break;
            }

            _progress( "downloading result…" );
            var resultJson = await http.GetStringAsync( $"{endpoint}/result" );
            var remote = VastProtocol.ParseResult( resultJson );   // throws w/ remote log on error
            return VastProtocol.ToRigResult( analysis, remote, modelTitle );
        }
        finally
        {
            if ( destroyAfter )
            {
                _progress( $"destroying instance {instanceId} (destroy-after-rig)…" );
                _progress( await _client.DestroyVerified( instanceId )
                    ? $"instance {instanceId} destroyed (verified)."
                    : $"WARNING: could not verify destruction of {instanceId} - check console.vast.ai." );
            }
            else
            {
                _progress( $"done - instance {instanceId} left running (offload never destroys it)." );
            }
        }
    }

    /// <summary>Installs a model onto an instance the user already has running
    /// (POST /provision), polling until the box reports it provisioned. Never
    /// rents, never destroys - it just makes the box ready to offload that model
    /// (a box can hold several). Downloads can be slow, so it waits generously.</summary>
    public async Task ProvisionOnExisting( long instanceId, string modelId )
    {
        _progress( $"connecting to instance {instanceId}…" );
        var endpoint = await WaitForBoot( instanceId );
        using var http = new HttpClient { Timeout = TimeSpan.FromMinutes( 5 ) };

        _progress( $"provisioning {modelId} (cloning repo + downloading checkpoints)…" );
        var post = await http.PostAsync(
            $"{endpoint}/provision?model={Uri.EscapeDataString( modelId )}",
            new ByteArrayContent( Array.Empty<byte>() ) );
        post.EnsureSuccessStatusCode();

        var deadline = DateTime.UtcNow + RigTimeout;
        while ( true )
        {
            if ( DateTime.UtcNow > deadline )
                throw new TimeoutException( "remote provision timed out." );
            await Task.Delay( TimeSpan.FromSeconds( 10 ) );
            var s = await http.GetStringAsync( $"{endpoint}/status" );
            if ( s.Contains( "\"provisioned" ) )
            {
                _progress( $"{modelId} provisioned - you can offload rigs to this box now." );
                return;
            }
            if ( s.Contains( "\"error\"" ) )
                throw new FormatException( $"remote provision failed: {s}" );
        }
    }

    async Task<string> WaitForBoot( long instanceId )
    {
        _progress( "waiting for the instance to boot…" );
        var deadline = DateTime.UtcNow + BootTimeout;
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds( 10 ) };
        while ( DateTime.UtcNow < deadline )
        {
            await Task.Delay( TimeSpan.FromSeconds( 10 ) );
            var state = await _client.GetInstance( instanceId );
            if ( state is null || state.ActualStatus != "running"
                || state.PublicIp.Length == 0
                || !state.Ports.TryGetValue( VastProtocol.ServerPort, out var hostPort ) )
                continue;

            var endpoint = $"http://{state.PublicIp}:{hostPort}";
            try
            {
                if ( (await http.GetStringAsync( $"{endpoint}/health" )).Contains( "ready" ) )
                {
                    _progress( "instance is up." );
                    return endpoint;
                }
            }
            catch { /* server not up yet */ }
        }
        throw new TimeoutException( "vast.ai instance did not become ready in time." );
    }

    static void ClearLedger()
    {
        if ( File.Exists( LedgerPath ) )
            File.Delete( LedgerPath );
    }
}
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using Sandbox.Diagnostics;

namespace ProtonMcpBridge;

/// <summary>
/// Loopback reachability check (mcp_bridge_selftest): connects to the listener from inside the editor
/// and does one round trip, confirming the transport is reachable under Wine/Proton.
/// </summary>
internal static class BridgeDiagnostics
{
	static readonly Logger log = new("ProtonMcpBridge");

	public static async Task SelfTest()
	{
		if (!TcpMcpServer.IsRunning)
		{
			log.Info("[selftest] server not running - nothing to probe");
			return;
		}

		log.Info($"[selftest] bound endpoint: {TcpMcpServer.BoundEndpoint ?? "(null)"}");

		await Probe(IPAddress.Loopback, "127.0.0.1");
	}

	static async Task Probe(IPAddress address, string label)
	{
		try
		{
			using var client = new TcpClient(address.AddressFamily);

			var connect = client.ConnectAsync(address, TcpMcpServer.Port);

			if (await Task.WhenAny(connect, Task.Delay(3000)) != connect)
			{
				log.Warning($"[selftest] {label}:{TcpMcpServer.Port} - connect timed out (3s)");
				return;
			}

			await connect; // surface any connect exception

			var body = """{"jsonrpc":"2.0","id":"selftest","method":"ping"}""";
			var request =
				"POST /mcp HTTP/1.1\r\n"
				+ $"Host: {label}:{TcpMcpServer.Port}\r\n"
				+ "Content-Type: application/json\r\n"
				+ $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n"
				+ "Connection: close\r\n\r\n"
				+ body;

			using var stream = client.GetStream();
			await stream.WriteAsync(Encoding.ASCII.GetBytes(request));
			await stream.FlushAsync();

			var buffer = new byte[4096];
			int read = await stream.ReadAsync(buffer);
			var responseLine = Encoding.UTF8.GetString(buffer, 0, read).Split("\r\n")[0];

			log.Info(
				$"[selftest] {label}:{TcpMcpServer.Port} - reachable, round trip ok: {responseLine}"
			);
		}
		catch (Exception e)
		{
			log.Warning(
				$"[selftest] {label}:{TcpMcpServer.Port} - failed: {e.GetType().Name}: {e.Message}"
			);
		}
	}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace GeneralGame.Editor;

/// <summary>
/// Helper for building asset context menus with Create Material/Texture options.
/// </summary>
public static class AssetContextMenuHelper
{
    // Builds the right-click menu for a single file/asset. Shared by the tree view and the icon grid
    // so both show identical options. Rename UI differs per view, so it is passed in via onRename.
    public static void BuildFileMenu(Menu menu, string fullPath, Asset asset, Action onRename, Action onChanged)
    {
        var fileName = Path.GetFileName(fullPath);

        if (asset != null)
            menu.AddOption("Open in Editor", "edit", () => asset.OpenInEditor());
        else
            menu.AddOption("Open", "open_in_new", () => EditorUtility.OpenFolder(fullPath));

        menu.AddOption("Show in Explorer", "folder_open", () => EditorUtility.OpenFileFolder(fullPath));

        menu.AddSeparator();

        if (asset != null)
            menu.AddOption("Copy Relative Path", "content_paste_go", () => EditorUtility.Clipboard.Copy(asset.RelativePath));
        menu.AddOption("Copy Absolute Path", "content_paste", () => EditorUtility.Clipboard.Copy(fullPath));

        // Asset-type specific options (Create Material, Create Texture, etc.)
        AddAssetTypeOptions(menu, asset);

        menu.AddSeparator();

        menu.AddOption("Rename", "edit", () => onRename?.Invoke());
        menu.AddOption("Duplicate", "file_copy", () =>
        {
            DuplicateFileWithRename(fullPath, () => onChanged?.Invoke());
        });

        menu.AddSeparator();

        var parentFolder = Path.GetDirectoryName(fullPath);
        if (!string.IsNullOrEmpty(parentFolder))
        {
            var createMenu = menu.AddMenu("Create", "add");
            AssetCreator.AddOptions(createMenu, parentFolder);
            menu.AddSeparator();
        }

        menu.AddOption("Delete", "delete", () =>
        {
            var confirm = new PopupWindow(
                "Delete File",
                $"Are you sure you want to delete '{fileName}'?",
                "Cancel",
                new Dictionary<string, Action>()
                {
                    { "Delete", () =>
                        {
                            try
                            {
                                DeleteFileWithCompiled(fullPath, asset);
                                onChanged?.Invoke();
                            }
                            catch (Exception ex)
                            {
                                Log.Error($"Failed to delete file: {ex.Message}");
                            }
                        }
                    }
                }
            );
            confirm.Show();
        });
    }

    // Builds the right-click menu for a folder. Shared by the tree view and the icon grid.
    public static void BuildFolderMenu(Menu menu, string fullPath, string displayName, bool isRoot,
        Action onOpen, Action onRename, Action onRefresh, Action onDeleted)
    {
        if (onOpen != null)
            menu.AddOption("Open", "folder_open", () => onOpen());

        menu.AddOption("Open in Explorer", "launch", () => EditorUtility.OpenFolder(fullPath));

        menu.AddSeparator();

        var createMenu = menu.AddMenu("Create", "add");
        AssetCreator.AddOptions(createMenu, fullPath);

        // Paste files copied from Windows Explorer into this folder
        if (WindowsClipboard.HasFiles())
        {
            menu.AddOption("Paste", "content_paste", () =>
            {
                PasteFromClipboard(fullPath);
                onRefresh?.Invoke();
            });
        }

        menu.AddSeparator();

        if (!isRoot)
            menu.AddOption("Rename", "edit", () => onRename?.Invoke());

        menu.AddOption("Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy(fullPath));
        menu.AddOption("Copy Relative Path", "content_copy", () =>
        {
            var relativePath = Path.GetRelativePath(Project.Current?.GetRootPath() ?? "", fullPath);
            EditorUtility.Clipboard.Copy(relativePath);
        });

        menu.AddSeparator();

        menu.AddOption("Refresh", "refresh", () => onRefresh?.Invoke());

        if (!isRoot)
        {
            menu.AddSeparator();
            menu.AddOption("Delete", "delete", () =>
            {
                var confirm = new PopupWindow(
                    "Delete Folder",
                    $"Are you sure you want to delete '{displayName}'?\nAll contents will be deleted.",
                    "Cancel",
                    new Dictionary<string, Action>()
                    {
                        { "Delete", () =>
                            {
                                try
                                {
                                    Directory.Delete(fullPath, recursive: true);
                                    onDeleted?.Invoke();
                                }
                                catch (Exception ex)
                                {
                                    Log.Error($"Failed to delete folder: {ex.Message}");
                                }
                            }
                        }
                    }
                );
                confirm.Show();
            });
        }
    }

    // Duplicates a file next to itself, finding a free "_copy" name.
    public static string DuplicateFile(string filePath)
    {
        try
        {
            var directory = Path.GetDirectoryName(filePath);
            var nameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
            var extension = Path.GetExtension(filePath);

            var newName = $"{nameWithoutExt}_copy{extension}";
            var newPath = Path.Combine(directory, newName);

            var counter = 1;
            while (File.Exists(newPath))
            {
                newName = $"{nameWithoutExt}_copy{counter++}{extension}";
                newPath = Path.Combine(directory, newName);
            }

            File.Copy(filePath, newPath);

            // Register the new file so it gets compiled and recognised as an asset immediately
            // (without this it shows up uncompiled until an editor restart or rename).
            AssetSystem.RegisterFile(newPath);

            return newPath;
        }
        catch (Exception ex)
        {
            Log.Error($"Failed to duplicate file: {ex.Message}");
            return null;
        }
    }

    // Duplicates a file and immediately opens a rename modal pre-filled with the copy's name.
    public static void DuplicateFileWithRename(string filePath, Action onComplete = null)
    {
        var newPath = DuplicateFile(filePath);
        if (string.IsNullOrEmpty(newPath))
            return;

        onComplete?.Invoke();

        var extension = Path.GetExtension(newPath);
        var dialog = new RenameDialog("Rename Duplicate", Path.GetFileNameWithoutExtension(newPath));
        dialog.OnConfirm = (newName) =>
        {
            if (string.IsNullOrWhiteSpace(newName))
                return;

            // Preserve extension if the user didn't type one
            if (!Path.HasExtension(newName))
                newName += extension;

            if (newName == Path.GetFileName(newPath))
                return;

            var renamedPath = Path.Combine(Path.GetDirectoryName(newPath), newName);
            if (File.Exists(renamedPath))
            {
                Log.Warning($"A file named '{newName}' already exists");
                return;
            }

            try
            {
                File.Move(newPath, renamedPath);

                // Drop the freshly compiled _c file so it doesn't linger under the old name
                var oldCompiled = newPath + "_c";
                if (File.Exists(oldCompiled))
                    File.Delete(oldCompiled);

                AssetSystem.RegisterFile(renamedPath);
                onComplete?.Invoke();
            }
            catch (Exception ex)
            {
                Log.Error($"Failed to rename duplicate: {ex.Message}");
            }
        };
        dialog.Show();
    }

    // Registers a freshly created/copied/moved path with the asset system so it compiles right away.
    // Accepts a single file or a directory (registers every file inside, recursively).
    public static void RegisterNewPath(string path)
    {
        try
        {
            if (Directory.Exists(path))
            {
                foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
                {
                    if (ShouldRegister(file))
                        AssetSystem.RegisterFile(file);
                }
            }
            else if (File.Exists(path) && ShouldRegister(path))
            {
                AssetSystem.RegisterFile(path);
            }
        }
        catch (Exception ex)
        {
            Log.Warning($"Failed to register '{path}': {ex.Message}");
        }
    }

    // Copies the files currently on the Windows clipboard into the target folder (always copy, never move).
    public static void PasteFromClipboard(string targetFolder)
    {
        if (string.IsNullOrEmpty(targetFolder) || !Directory.Exists(targetFolder))
            return;

        foreach (var file in WindowsClipboard.GetFiles())
        {
            if (string.IsNullOrEmpty(file))
                continue;

            try
            {
                var name = Path.GetFileName(file.TrimEnd('\\', '/'));
                var destPath = Path.Combine(targetFolder, name);

                // Don't paste a folder into itself
                if (Path.GetFullPath(file).Equals(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
                    destPath = MakeUniquePath(destPath);
                else if (File.Exists(destPath) || Directory.Exists(destPath))
                    destPath = MakeUniquePath(destPath);

                if (Directory.Exists(file))
                    CopyDirectory(file, destPath);
                else if (File.Exists(file))
                    File.Copy(file, destPath, overwrite: false);
                else
                    continue;

                RegisterNewPath(destPath);
            }
            catch (Exception ex)
            {
                Log.Error($"Failed to paste '{file}': {ex.Message}");
            }
        }
    }

    private static string MakeUniquePath(string path)
    {
        if (!File.Exists(path) && !Directory.Exists(path))
            return path;

        var directory = Path.GetDirectoryName(path);
        var name = Path.GetFileNameWithoutExtension(path);
        var ext = Path.GetExtension(path);

        int counter = 1;
        string candidate;
        do
        {
            var suffix = counter == 1 ? "_copy" : $"_copy{counter}";
            candidate = Path.Combine(directory, $"{name}{suffix}{ext}");
            counter++;
        }
        while (File.Exists(candidate) || Directory.Exists(candidate));

        return candidate;
    }

    private static void CopyDirectory(string sourceDir, string destDir)
    {
        Directory.CreateDirectory(destDir);

        foreach (var file in Directory.GetFiles(sourceDir))
        {
            File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)));
        }

        foreach (var dir in Directory.GetDirectories(sourceDir))
        {
            CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
        }
    }

    // True when the path lives outside the current project (e.g. dragged in from Windows Explorer).
    // Such sources must be copied, never moved, so we don't delete files from their original location.
    public static bool IsExternalSource(string path)
    {
        try
        {
            var root = Project.Current?.GetRootPath();
            if (string.IsNullOrEmpty(root))
                return false;

            var full = Path.GetFullPath(path);
            var rootFull = Path.GetFullPath(root);
            return !full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase);
        }
        catch
        {
            return false;
        }
    }

    // Create a prefab from GameObject(s) dragged in from the scene hierarchy.
    // A single object becomes the prefab root directly; multiple objects are
    // wrapped under a new root, mirroring the engine's "Convert to Prefab".
    public static void CreatePrefabFromGameObjects(GameObject[] gameObjects, string targetFolder, Action onComplete = null)
    {
        if (gameObjects == null || string.IsNullOrEmpty(targetFolder)) return;

        var selection = gameObjects.Where(g => g != null).ToArray();
        var first = selection.FirstOrDefault();
        if (first == null) return;

        try
        {
            var session = SceneEditorSession.Resolve(first);
            if (session == null) return;

            var baseName = string.IsNullOrWhiteSpace(first.Name) ? "Prefab" : first.Name;
            var location = GetUniquePrefabPath(targetFolder, baseName);

            using var scene = session.Scene.Push();
            using (session.UndoScope("Create Prefab from Hierarchy").WithGameObjectChanges(selection, GameObjectUndoFlags.All).WithGameObjectCreations().Push())
            {
                GameObject prefabRoot;

                if (selection.Length == 1)
                {
                    prefabRoot = first;
                }
                else
                {
                    prefabRoot = new GameObject();
                    prefabRoot.WorldTransform = first.WorldTransform;

                    foreach (var go in selection)
                        go.SetParent(prefabRoot, true);
                }

                prefabRoot.Name = Path.GetFileNameWithoutExtension(location);

                EditorUtility.Prefabs.ConvertGameObjectToPrefab(prefabRoot, location);
                EditorUtility.InspectorObject = prefabRoot;
            }
        }
        catch (Exception ex)
        {
            Log.Error($"Failed to create prefab: {ex.Message}");
        }

        onComplete?.Invoke();
    }

    // Build a non-colliding "name.prefab" path inside the target folder.
    private static string GetUniquePrefabPath(string folder, string baseName)
    {
        var path = Path.Combine(folder, $"{baseName}.prefab");
        int n = 1;
        while (File.Exists(path) || AssetSystem.FindByPath(path) != null)
        {
            path = Path.Combine(folder, $"{baseName} ({n}).prefab");
            n++;
        }
        return path;
    }

    // ===== Backward compatibility: move an asset and optionally fix references to its old path =====

    private static readonly HashSet<string> NonTextExtensions = new(StringComparer.OrdinalIgnoreCase)
    {
        ".png", ".jpg", ".jpeg", ".tga", ".psd", ".bmp", ".gif", ".dds", ".hdr",
        ".fbx", ".obj", ".gltf", ".glb", ".dmx", ".blend",
        ".wav", ".mp3", ".ogg", ".flac",
        ".vtex", ".vsnd", ".vmdl", ".dll", ".pdb", ".exe", ".zip", ".bin"
    };

    // True if this is a single registered asset being moved inside the project - the case the
    // backward-compatibility reference check applies to.
    public static bool IsBackwardCompatMove(IReadOnlyList<string> files, bool isCopy)
    {
        if (!BrowserSettings.BackwardCompatibility) return false;
        if (isCopy) return false;
        if (files == null || files.Count != 1) return false;

        var file = files[0];
        if (string.IsNullOrEmpty(file) || IsExternalSource(file) || Directory.Exists(file)) return false;
        if (!File.Exists(file)) return false;

        var asset = AssetSystem.FindByPath(file);
        return asset != null && !asset.IsDeleted;
    }

    // Moves an asset, first asking (via a modal) whether to update references to its old path.
    public static void MoveAssetWithReferenceCheck(string sourceFile, string targetFolder, Action onComplete)
    {
        var asset = AssetSystem.FindByPath(sourceFile);
        if (asset == null || asset.IsDeleted)
            return;

        // Don't bother if it's already in the target folder
        if (string.Equals(Path.GetFullPath(Path.GetDirectoryName(sourceFile)), Path.GetFullPath(targetFolder), StringComparison.OrdinalIgnoreCase))
            return;

        var assetsRoot = Project.Current?.GetAssetsPath();
        var oldRef = asset.RelativePath?.Replace('\\', '/');
        var newAbs = Path.Combine(targetFolder, Path.GetFileName(sourceFile));
        var newRef = string.IsNullOrEmpty(assetsRoot)
            ? null
            : Path.GetRelativePath(assetsRoot, newAbs).Replace('\\', '/');

        void JustMove()
        {
            EditorUtility.MoveAssetToDirectory(asset, targetFolder);
            onComplete?.Invoke();
        }

        // Can't compute a clean reference (asset outside the assets mount) - just move normally
        if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || newRef.StartsWith(".."))
        {
            JustMove();
            return;
        }

        var affected = FindFilesReferencing(oldRef);
        affected.RemoveAll(f => string.Equals(Path.GetFullPath(f), Path.GetFullPath(asset.AbsolutePath), StringComparison.OrdinalIgnoreCase));

        var dialog = new MoveReferencesDialog(oldRef, newRef, affected,
            onJustMove: JustMove,
            onMoveAndUpdate: () =>
            {
                EditorUtility.MoveAssetToDirectory(asset, targetFolder);
                RewriteReferences(affected, oldRef, newRef);
                onComplete?.Invoke();
            });
        dialog.Show();
    }

    // Matches the reference only at a path boundary, so "models/foo.vmdl" doesn't match inside
    // "othermodels/foo.vmdl" or "sub/models/foo.vmdl". A trailing "_c" (compiled form) is still matched.
    private static Regex BuildReferenceRegex(string reference)
    {
        return new Regex(@"(?<![\w./\\-])" + Regex.Escape(reference), RegexOptions.IgnoreCase);
    }

    // Finds every text-based project file that mentions the given reference path.
    public static List<string> FindFilesReferencing(string reference)
    {
        var results = new List<string>();
        if (string.IsNullOrEmpty(reference))
            return results;

        var root = Project.Current?.GetAssetsPath();
        if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
            return results;

        var regex = BuildReferenceRegex(reference);

        foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
        {
            if (!IsScannableTextFile(file))
                continue;

            try
            {
                var info = new FileInfo(file);
                if (info.Length == 0 || info.Length > 16_000_000)
                    continue;

                var text = File.ReadAllText(file);
                if (regex.IsMatch(text))
                    results.Add(file);
            }
            catch
            {
                // Ignore unreadable files
            }
        }

        return results;
    }

    // Rewrites the old reference to the new one in each file (covers compiled "_c" forms too, since
    // they share the prefix). Re-registers the file afterwards.
    public static void RewriteReferences(IEnumerable<string> files, string oldRef, string newRef)
    {
        var regex = BuildReferenceRegex(oldRef);

        foreach (var file in files)
        {
            try
            {
                var text = File.ReadAllText(file);
                var updated = regex.Replace(text, _ => newRef);

                if (updated != text)
                {
                    File.WriteAllText(file, updated);
                    AssetSystem.RegisterFile(file);
                }
            }
            catch (Exception ex)
            {
                Log.Error($"Failed to update references in '{file}': {ex.Message}");
            }
        }
    }

    private static bool IsScannableTextFile(string file)
    {
        if (!ShouldRegister(file))
            return false;

        var ext = Path.GetExtension(file);
        if (NonTextExtensions.Contains(ext))
            return false;

        return true;
    }

    private static bool ShouldRegister(string file)
    {
        var name = Path.GetFileName(file);
        if (name.StartsWith(".")) return false;
        if (name.EndsWith("_c", StringComparison.OrdinalIgnoreCase)) return false;
        if (name.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false;
        if (name.Contains(".generated", StringComparison.OrdinalIgnoreCase)) return false;
        return true;
    }

    // Deletes a file, also removing its compiled "_c" sibling. Uses Asset.Delete when registered.
    public static void DeleteFileWithCompiled(string fullPath, Asset asset)
    {
        if (asset != null)
        {
            asset.Delete();
            return;
        }

        File.Delete(fullPath);

        var compiledPath = fullPath + "_c";
        if (File.Exists(compiledPath))
        {
            File.Delete(compiledPath);
        }
    }

    private static readonly HashSet<string> MeshExtensions =
        new(StringComparer.OrdinalIgnoreCase) { ".fbx", ".obj", ".dmx", ".gltf", ".glb" };

    // Builds the right-click menu shown when several files are selected at once.
    // Shared by the tree view and the icon grid so both behave identically.
    public static void BuildMultiFileMenu(Menu menu, List<(string Path, Asset Asset)> items, Action onChanged)
    {
        if (items == null || items.Count == 0) return;

        int count = items.Count;
        var assets = items.Where(i => i.Asset != null).Select(i => i.Asset).ToList();

        // Asset-type batch options (Create Material (N), Create Texture (N), etc.)
        bool addedTypeOptions = AddMultiAssetTypeOptions(menu, assets);

        if (addedTypeOptions)
            menu.AddSeparator();

        menu.AddOption($"Duplicate ({count})", "file_copy", () =>
        {
            foreach (var it in items)
                DuplicateFile(it.Path);
            onChanged?.Invoke();
        });

        menu.AddOption($"Delete ({count})", "delete", () =>
        {
            var confirm = new PopupWindow(
                "Delete Files",
                $"Are you sure you want to delete {count} item(s)?",
                "Cancel",
                new Dictionary<string, Action>()
                {
                    { "Delete", () =>
                        {
                            foreach (var it in items)
                            {
                                try
                                {
                                    DeleteFileWithCompiled(it.Path, it.Asset);
                                }
                                catch (Exception ex)
                                {
                                    Log.Error($"Failed to delete '{it.Path}': {ex.Message}");
                                }
                            }
                            onChanged?.Invoke();
                        }
                    }
                }
            );
            confirm.Show();
        });
    }

    // Adds asset-type specific batch options with a count suffix, e.g. "Create Material (3)".
    // Each option auto-creates the result next to every source asset (no save dialog).
    // Returns true if any option was added.
    public static bool AddMultiAssetTypeOptions(Menu menu, List<Asset> assets)
    {
        if (assets == null || assets.Count == 0) return false;

        var images = assets.Where(a => a.AssetType == AssetType.ImageFile).ToList();
        var shaders = assets.Where(a => a.AssetType == AssetType.Shader).ToList();
        var meshes = assets.Where(a => MeshExtensions.Contains(Path.GetExtension(a.AbsolutePath))).ToList();

        bool added = false;

        if (images.Count > 0)
        {
            menu.AddOption($"Create Material ({images.Count})", "image", () =>
            {
                foreach (var a in images) CreateMaterialFromImageAuto(a);
                Log.Info($"Created {images.Count} material(s)");
            });
            menu.AddOption($"Create Texture ({images.Count})", "texture", () =>
            {
                foreach (var a in images) CreateTextureFromImageAuto(a);
            });
            menu.AddOption($"Create Sprite ({images.Count})", "emoji_emotions", () =>
            {
                foreach (var a in images) CreateSpriteFromImageAuto(a);
            });
            added = true;
        }

        if (shaders.Count > 0)
        {
            if (added) menu.AddSeparator();
            menu.AddOption($"Create Material ({shaders.Count})", "image", () =>
            {
                foreach (var a in shaders) CreateMaterialFromShaderAuto(a);
            });
            added = true;
        }

        if (meshes.Count > 0)
        {
            if (added) menu.AddSeparator();
            menu.AddOption($"Create Model ({meshes.Count})", "view_in_ar", () =>
            {
                foreach (var a in meshes) CreateModelFromMeshAuto(a);
            });
            added = true;
        }

        var sounds = assets.Where(a => a.AssetType == AssetType.SoundFile).ToList();
        if (sounds.Count > 0)
        {
            if (added) menu.AddSeparator();

            // One sound event per file
            menu.AddOption($"Create Sound Event ({sounds.Count})", "graphic_eq", () =>
            {
                foreach (var a in sounds) CreateSoundEventFromAudioAuto(a);
                Log.Info($"Created {sounds.Count} sound event(s)");
            });

            // A single sound event that randomly picks between all the selected sounds
            menu.AddOption($"Create Random Sound Event ({sounds.Count})", "shuffle", () =>
            {
                CreateRandomSoundEventFromAudios(sounds);
            });

            added = true;
        }

        return added;
    }

    /// <summary>
    /// Add asset-type specific options like Create Material, Create Texture, etc.
    /// </summary>
    public static void AddAssetTypeOptions(Menu menu, Asset asset)
    {
        if (asset == null) return;

        var assetType = asset.AssetType;
        if (assetType == null) return;

        // Image files - can create Material, Texture, Sprite
        if (assetType == AssetType.ImageFile)
        {
            menu.AddSeparator();
            menu.AddOption("Create Material", "image", () => CreateMaterialFromImage(asset));
            menu.AddOption("Create Texture", "texture", () => CreateTextureFromImage(asset));
            menu.AddOption("Create Sprite", "emoji_emotions", () => CreateSpriteFromImage(asset));
        }

        // Shader files - can create Material
        if (assetType == AssetType.Shader)
        {
            menu.AddSeparator();
            menu.AddOption("Create Material", "image", () => CreateMaterialFromShader(asset));
        }

        // Mesh files (FBX, OBJ) - can create Model
        if (MeshExtensions.Contains(Path.GetExtension(asset.AbsolutePath)))
        {
            menu.AddSeparator();
            menu.AddOption("Create Model", "view_in_ar", () => CreateModelFromMesh(asset));
        }

        // Sound files - can create a Sound Event
        if (assetType == AssetType.SoundFile)
        {
            menu.AddSeparator();
            menu.AddOption("Create Sound Event", "graphic_eq", () => CreateSoundEventFromAudio(asset));
        }
    }

    private static void CreateTextureFromImage(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Texture from Image..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".vtex";
        fd.SelectFile($"{asset.Name}.vtex");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Texture File (*.vtex)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildVtexContent(asset));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    private static void CreateMaterialFromImage(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Material from Image..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".vmat";
        fd.SelectFile($"{GetMaterialBaseName(asset)}.vmat");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Material File (*.vmat)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildImageMaterialContent(asset));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    private static async void CreateSpriteFromImage(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Sprite from Image..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".sprite";
        fd.SelectFile($"{asset.Name}.sprite");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Sprite File (*.sprite)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildSpriteContent(asset));

        var resultAsset = AssetSystem.RegisterFile(fd.SelectedFile);
        while (!resultAsset.IsCompiledAndUpToDate)
        {
            await Task.Delay(10);
        }
    }

    private static void CreateMaterialFromShader(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Material from Shader..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".vmat";
        fd.SelectFile($"{asset.Name}.vmat");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Material File (*.vmat)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildShaderMaterialContent(asset));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    private static void CreateModelFromMesh(Asset asset)
    {
        var targetPath = EditorUtility.SaveFileDialog("Create Model..", "vmdl", Path.ChangeExtension(asset.AbsolutePath, "vmdl"));
        if (targetPath == null)
            return;

        EditorUtility.CreateModelFromMeshFile(asset, targetPath);
    }

    private static void CreateSoundEventFromAudio(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Sound Event..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".sound";
        fd.SelectFile($"{asset.Name}.sound");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Sound Event (*.sound)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    // ===== Batch creators: auto-name next to each source asset, skip if it already exists =====

    private static void CreateMaterialFromImageAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{GetMaterialBaseName(asset)}.vmat");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildImageMaterialContent(asset));
        AssetSystem.RegisterFile(destPath);
    }

    private static void CreateTextureFromImageAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.vtex");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildVtexContent(asset));
        AssetSystem.RegisterFile(destPath);
    }

    private static async void CreateSpriteFromImageAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.sprite");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildSpriteContent(asset));

        var resultAsset = AssetSystem.RegisterFile(destPath);
        while (!resultAsset.IsCompiledAndUpToDate)
        {
            await Task.Delay(10);
        }
    }

    private static void CreateMaterialFromShaderAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.vmat");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildShaderMaterialContent(asset));
        AssetSystem.RegisterFile(destPath);
    }

    private static void CreateModelFromMeshAuto(Asset asset)
    {
        var destPath = Path.ChangeExtension(asset.AbsolutePath, "vmdl");
        if (File.Exists(destPath))
            return;

        EditorUtility.CreateModelFromMeshFile(asset, destPath);
    }

    private static void CreateSoundEventFromAudioAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.sound");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
        AssetSystem.RegisterFile(destPath);
    }

    // Creates a single sound event that randomly picks between all the given audio files.
    private static void CreateRandomSoundEventFromAudios(List<Asset> assets)
    {
        if (assets == null || assets.Count == 0)
            return;

        var first = assets[0];
        var directory = Path.GetDirectoryName(first.AbsolutePath);
        var destPath = FindFreePath(directory, GetSoundBaseName(first), ".sound");

        var references = assets.Select(GetVsndReference).ToList();
        File.WriteAllText(destPath, BuildSoundEventContent(references));
        AssetSystem.RegisterFile(destPath);
    }

    // ===== Content builders shared by single and batch creators =====

    // Strips trailing texture-role suffixes (_color, _normal, ...) to get the material base name.
    private static string GetMaterialBaseName(Asset asset)
    {
        string[] suffixes = { "color", "ao", "normal", "metallic", "rough", "diff", "diffuse", "nrm", "spec", "selfillum", "mask" };

        var assetName = asset.Name;
        foreach (var t in suffixes)
        {
            if (assetName.EndsWith($"_{t}"))
                assetName = assetName.Substring(0, assetName.Length - (t.Length + 1));
        }
        return assetName;
    }

    // Builds a complex.shader material, auto-wiring sibling textures (normal/ao/rough/...) by name.
    private static string BuildImageMaterialContent(Asset asset)
    {
        var assetName = GetMaterialBaseName(asset);

        var assetPath = Path.GetDirectoryName(asset.AbsolutePath).NormalizeFilename(false);
        var assetPeers = AssetSystem.All
            .Where(x => x.AssetType == AssetType.ImageFile)
            .Where(x => x.AbsolutePath.StartsWith(assetPath))
            .ToArray();

        var assetPeersWithSameBaseName = assetPeers
            .Where(x => x.Name == assetName || x.Name.StartsWith(assetName + "_"))
            .ToArray();

        if (assetPeersWithSameBaseName.Length > 0)
        {
            assetPeers = assetPeersWithSameBaseName;
        }

        string texColor = assetPeers.Where(x => x.Name.Contains("_color") || x.Name.Contains("_diff")).Select(x => x.RelativePath).FirstOrDefault();
        texColor ??= asset.RelativePath;

        string texNormal = assetPeers.Where(x => x.Name.Contains("_nrm") || x.Name.Contains("_normal") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_normal.tga";
        string texAo = assetPeers.Where(x => x.Name.Contains("_ao") || x.Name.Contains("_occ") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_ao.tga";
        string texRough = assetPeers.Where(x => x.Name.Contains("_rough")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_rough.tga";

        string texMetallic = assetPeers.Where(x => x.Name.Contains("_metallic")).Select(x => x.RelativePath).FirstOrDefault();
        if (texMetallic != null)
        {
            texMetallic = $"\n\tF_METALNESS_TEXTURE 1\n\tF_SPECULAR 1\n\tTextureMetalness \"{texMetallic}\"";
        }

        string texSelfIllum = assetPeers.Where(x => x.Name.Contains("_selfillum")).Select(x => x.RelativePath).FirstOrDefault();
        if (texSelfIllum != null)
        {
            texSelfIllum = $"\n\tF_SELF_ILLUM 1\n\tTextureSelfIllumMask \"{texSelfIllum}\"";
        }

        string tintMask = assetPeers.Where(x => x.Name.Contains("_mask")).Select(x => x.RelativePath).FirstOrDefault();
        if (tintMask != null)
        {
            tintMask = $"\n\tF_TINT_MASK 1\n\tTextureTintMask \"{tintMask}\"";
        }

        return $@"
Layer0
{{
	shader ""shaders/complex.shader_c""

	TextureColor ""{texColor}""
	TextureAmbientOcclusion ""{texAo}""
	TextureNormal ""{texNormal}""
	TextureRoughness ""{texRough}""{texMetallic}{texSelfIllum}{tintMask}

}}
";
    }

    private static string BuildShaderMaterialContent(Asset asset)
    {
        var shaderPath = asset.GetCompiledFile();

        return $@"
Layer0
{{
	shader ""{shaderPath}""

}}
";
    }

    private static string BuildVtexContent(Asset asset)
    {
        var vtexContent = new Dictionary<string, object>
        {
            { "Sequences", new object[]
                {
                    new Dictionary<string, object>
                    {
                        { "Source", asset.RelativePath },
                        { "IsLooping", true }
                    }
                }
            }
        };

        return Json.Serialize(vtexContent);
    }

    private static string BuildSpriteContent(Asset asset)
    {
        var path = Path.ChangeExtension(asset.Path, Path.GetExtension(asset.AbsolutePath));
        var sprite = Sprite.FromTexture(Texture.Load(path));
        return sprite.Serialize().ToJsonString();
    }

    // Builds a .sound (SoundEvent) resource that plays a random sound from the given references.
    private static string BuildSoundEventContent(IEnumerable<string> vsndReferences)
    {
        var content = new Dictionary<string, object>
        {
            { "Volume", "1" },
            { "Pitch", "1" },
            { "SelectionMode", "Random" },
            { "Sounds", vsndReferences.ToArray() },
            { "__version", 1 }
        };

        return Json.Serialize(content);
    }

    // Sound events reference the compiled .vsnd, regardless of the source file extension.
    private static string GetVsndReference(Asset asset)
    {
        return Path.ChangeExtension(asset.RelativePath, "vsnd").Replace('\\', '/');
    }

    // Strips a trailing numeric index so "footstep_01" -> "footstep" for naming a combined event.
    private static string GetSoundBaseName(Asset asset)
    {
        var name = asset.Name;
        var underscore = name.LastIndexOf('_');
        if (underscore > 0 && int.TryParse(name.Substring(underscore + 1), out _))
            name = name.Substring(0, underscore);
        return name;
    }

    private static string FindFreePath(string directory, string baseName, string extension)
    {
        var path = Path.Combine(directory, $"{baseName}{extension}");

        int counter = 1;
        while (File.Exists(path))
            path = Path.Combine(directory, $"{baseName}_{counter++}{extension}");

        return path;
    }
}
using System;
using Sandbox;
using Editor;

/// <summary>
/// Modal-style confirmation dialog with Overwrite/Cancel buttons.
/// Use ConfirmDialog.Show(title, message, onConfirm) to display.
/// </summary>
public class ConfirmDialog : PaintedWindow
{
	private readonly string _title;
	private readonly string _message;
	private readonly string _detail;
	private readonly Action _onConfirm;
	private Vector2 _mousePos;

	private ConfirmDialog( string title, string message, string detail, Action onConfirm )
	{
		_title = title;
		_message = message;
		_detail = detail;
		_onConfirm = onConfirm;
		Title = title;
		Size = new Vector2( 420, string.IsNullOrEmpty( detail ) ? 180 : 240 );
	}

	/// <summary>
	/// Show a confirmation dialog with Overwrite and Cancel buttons.
	/// </summary>
	public static void Show( string title, string message, Action onConfirm, string detail = null )
	{
		var dialog = new ConfirmDialog( title, message, detail, onConfirm );
		dialog.Show();
	}

	protected override void OnContentPaint()
	{
		base.OnContentPaint();

		var pad = 20f;
		var w = Width - pad * 2;
		var y = 20f;

		// Title
		Paint.SetDefaultFont( size: 13, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad, y, w, 22 ), _title, TextFlag.LeftCenter );
		y += 32;

		// Message
		Paint.SetDefaultFont( size: 10 );
		Paint.SetPen( Color.White.WithAlpha( 0.85f ) );

		// Word-wrap the message manually
		var words = _message.Split( ' ' );
		var line = "";
		foreach ( var word in words )
		{
			var test = string.IsNullOrEmpty( line ) ? word : $"{line} {word}";
			if ( test.Length * 6.5f > w && !string.IsNullOrEmpty( line ) )
			{
				Paint.DrawText( new Rect( pad, y, w, 16 ), line, TextFlag.LeftCenter );
				y += 17;
				line = word;
			}
			else
			{
				line = test;
			}
		}
		if ( !string.IsNullOrEmpty( line ) )
		{
			Paint.DrawText( new Rect( pad, y, w, 16 ), line, TextFlag.LeftCenter );
			y += 20;
		}

		// Detail (smaller, dimmer)
		if ( !string.IsNullOrEmpty( _detail ) )
		{
			y += 4;
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.Orange.WithAlpha( 0.7f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ), _detail, TextFlag.LeftCenter );
			y += 22;
		}

		y += 8;

		// Buttons row
		var btnH = 32f;
		var btnW = ( w - 12 ) / 2;

		// Cancel button (left)
		var cancelRect = new Rect( pad, y, btnW, btnH );
		var cancelHovered = cancelRect.IsInside( _mousePos );
		Paint.SetBrush( Color.White.WithAlpha( cancelHovered ? 0.1f : 0.04f ) );
		Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.3f : 0.15f ) );
		Paint.DrawRect( cancelRect, 4 );
		Paint.SetDefaultFont( size: 11, weight: 600 );
		Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.9f : 0.6f ) );
		Paint.DrawText( cancelRect, "Cancel", TextFlag.Center );

		// Overwrite button (right)
		var overwriteRect = new Rect( pad + btnW + 12, y, btnW, btnH );
		var overwriteHovered = overwriteRect.IsInside( _mousePos );
		Paint.SetBrush( Color.Red.WithAlpha( overwriteHovered ? 0.25f : 0.12f ) );
		Paint.SetPen( Color.Red.WithAlpha( overwriteHovered ? 0.6f : 0.3f ) );
		Paint.DrawRect( overwriteRect, 4 );
		Paint.SetDefaultFont( size: 11, weight: 700 );
		Paint.SetPen( Color.Red.WithAlpha( overwriteHovered ? 1f : 0.8f ) );
		Paint.DrawText( overwriteRect, "Overwrite", TextFlag.Center );

		_cancelRect = cancelRect;
		_overwriteRect = overwriteRect;
	}

	private Rect _cancelRect;
	private Rect _overwriteRect;

	protected override void OnContentMousePress( MouseEvent e )
	{
		base.OnContentMousePress(e);

		if ( _cancelRect.IsInside( e.LocalPosition ) )
		{
			Close();
		}
		else if ( _overwriteRect.IsInside( e.LocalPosition ) )
		{
			Close();
			_onConfirm?.Invoke();
		}
	}

	protected override void OnContentMouseMove( MouseEvent e )
	{
		base.OnContentMouseMove(e);
		_mousePos = e.LocalPosition;
		Update();
	}
}
using Editor;

/// <summary>
/// Standalone editor window whose content is rendered and interacted with through
/// the window's central widget. Current s&amp;box windows reserve their root widget
/// for window chrome, so custom content must live on <see cref="Window.Canvas"/>.
/// </summary>
public abstract class PaintedWindow : Window
{
	private readonly ContentWidget _content;

	protected PaintedWindow()
	{
		_content = new ContentWidget( this );
		Canvas = _content;
	}

	/// <summary>
	/// Parent for native controls that must appear above the painted content.
	/// </summary>
	protected Widget Content => _content;

	public new float Width
	{
		get => _content.Width;
		set => base.Width = value;
	}

	public new float Height
	{
		get => _content.Height;
		set => base.Height = value;
	}

	public new bool MouseTracking
	{
		get => _content.MouseTracking;
		set
		{
			base.MouseTracking = value;
			_content.MouseTracking = value;
		}
	}

	public override void Update()
	{
		base.Update();
		_content.Update();
	}

	protected virtual void OnContentPaint()
	{
	}

	protected virtual void OnContentMousePress( MouseEvent e )
	{
	}

	protected virtual void OnContentMouseMove( MouseEvent e )
	{
	}

	protected virtual void OnContentMouseWheel( WheelEvent e )
	{
	}

	protected virtual void OnContentKeyPress( KeyEvent e )
	{
	}

	private sealed class ContentWidget : Widget
	{
		private readonly PaintedWindow _owner;

		public ContentWidget( PaintedWindow owner )
		{
			_owner = owner;
			MouseTracking = true;
			FocusMode = FocusMode.TabOrClickOrWheel;
		}

		protected override void OnPaint()
		{
			base.OnPaint();
			_owner.OnContentPaint();
		}

		protected override void OnMousePress( MouseEvent e )
		{
			base.OnMousePress( e );
			_owner.OnContentMousePress( e );
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			base.OnMouseMove( e );
			_owner.OnContentMouseMove( e );
		}

		protected override void OnMouseWheel( WheelEvent e )
		{
			base.OnMouseWheel( e );
			_owner.OnContentMouseWheel( e );
		}

		protected override void OnKeyPress( KeyEvent e )
		{
			base.OnKeyPress( e );
			_owner.OnContentKeyPress( e );
		}
	}
}
using System;
using Sandbox;

namespace Editor.SuperShot;

public sealed class SettingsPanel : Widget
{
	readonly SuperShotWindow _window;

	public SettingsPanel( SuperShotWindow window ) : base( null )
	{
		_window = window;
		Name = "Settings";
		WindowTitle = "Settings";
		SetWindowIcon( "settings" );
		Layout = Layout.Column();
		Layout.Margin = 8;
		Layout.Spacing = 8;

		Build();
	}

	void Build()
	{
		SuperShotUI.AddBanner( Layout, "Settings", "Where shots are saved and how they're named.", "settings" );

		var scroll = new ScrollArea( this );
		scroll.Canvas = new Widget( scroll );
		scroll.Canvas.Layout = Layout.Column();
		scroll.Canvas.Layout.Spacing = 8;
		var body = scroll.Canvas.Layout;

		var captureSo = _window.Settings.Capture.GetSerialized();
		captureSo.OnPropertyChanged += _ =>
		{
			_window.Settings.Save();
			_window.NotifyChanged();
		};
		var resCard = SuperShotUI.AddCard( body, "Default Capture Resolution", "aspect_ratio" );
		resCard.Body.Add( new Label( "Used by the main Capture button. Quick presets and package thumbnails override this per click." ) );
		resCard.Body.Add( SuperShotUI.SheetWidget( captureSo, IsResolutionProp ) );

		var content = SuperShotUI.AddCard( body, "Capture Content", "visibility" );
		content.Body.Add( new Label( "Controls what non-world content is included when Supershot renders a capture." ) { WordWrap = true } );
		content.Body.Add( SuperShotUI.SheetWidget( captureSo, IsCaptureContentProp ) );

		var outputSo = _window.Settings.Output.GetSerialized();
		outputSo.OnPropertyChanged += _ => _window.Settings.Save();

		var output = SuperShotUI.AddCard( body, "Output", "folder" );
		output.Body.Add( SuperShotUI.SheetWidget( outputSo, IsOutputEssential ) );

		SuperShotUI.AddSection( body, "Advanced Output", "tune",
			SuperShotUI.SheetWidget( outputSo, p => !IsOutputEssential( p ) ),
			cookie: "supershot.settings.outputadvanced" );

		body.AddStretchCell();
		Layout.Add( scroll, 1 );

		var row = Layout.AddRow();
		row.Spacing = 4;
		row.Add( new Button( "Open Output Folder", "folder_open" )
		{
			Clicked = () => SuperShotService.RevealInExplorer( _window.Settings.Output.ResolveFolder() )
		} );
		row.AddStretchCell();
	}

	static bool IsResolutionProp( SerializedProperty p )
	{
		return p.Name is nameof( CaptureSettings.Resolution )
			or nameof( CaptureSettings.CustomWidth )
			or nameof( CaptureSettings.CustomHeight );
	}

	static bool IsCaptureContentProp( SerializedProperty p )
	{
		return p.Name is nameof( CaptureSettings.ShowGameUI )
			or nameof( CaptureSettings.TransparentBackground )
			or nameof( CaptureSettings.HideTags );
	}

	static bool IsOutputEssential( SerializedProperty p )
	{
		return p.Name is nameof( OutputSettings.OutputFolder )
			or nameof( OutputSettings.Format )
			or nameof( OutputSettings.Quality );
	}
}
using System;
using Sandbox;

namespace Editor.SuperShot;

public static class SuperShotUI
{
	public static Color Accent => new( 0.36f, 0.55f, 0.95f );
	public static Color AccentDim => new( 0.22f, 0.30f, 0.46f );
	public static Color CardBackground => Theme.ControlBackground.Lighten( 0.03f );
	public static Color CardBorder => Theme.ControlBackground.Lighten( 0.12f );
	public static Color Muted => Theme.TextControl.WithAlpha( 0.6f );

	public static Banner AddBanner( Layout layout, string title, string subtitle, string icon )
	{
		var banner = new Banner( title, subtitle, icon );
		layout.Add( banner );
		return banner;
	}

	public static Card AddCard( Layout layout, string title = null, string icon = null )
	{
		var card = new Card( title, icon );
		layout.Add( card );
		return card;
	}

	public static ExpandGroup AddSection( Layout layout, string title, string icon, Widget content, string cookie = null, bool defaultOpen = false )
	{
		var group = new ExpandGroup( null );
		group.Title = title;
		group.Icon = icon;
		group.SetWidget( content );

		if ( !string.IsNullOrEmpty( cookie ) )
			group.StateCookieName = cookie;
		else
			group.SetOpenState( defaultOpen );

		layout.Add( group );
		return group;
	}

	public static Widget SheetWidget( SerializedObject so, Func<SerializedProperty, bool> filter = null )
	{
		var widget = new Widget( null );
		widget.Layout = Layout.Column();

		var sheet = new ControlSheet();
		if ( filter is null )
			sheet.AddObject( so );
		else
			sheet.AddObject( so, filter );

		widget.Layout.Add( sheet );
		return widget;
	}
}

public sealed class Banner : Widget
{
	public string TitleText { get; set; }
	public string SubtitleText { get; set; }
	public string Icon { get; set; }

	public Banner( string title, string subtitle, string icon ) : base( null )
	{
		TitleText = title;
		SubtitleText = subtitle;
		Icon = icon;
		FixedHeight = 56;
		MinimumSize = new Vector2( 0, 56 );
	}

	protected override void OnPaint()
	{
		var rect = LocalRect;

		Paint.ClearPen();
		Paint.SetBrush( SuperShotUI.AccentDim.WithAlpha( 0.35f ) );
		Paint.DrawRect( rect, 6f );

		Paint.ClearBrush();
		Paint.SetBrush( SuperShotUI.Accent );
		Paint.DrawRect( new Rect( rect.Left, rect.Top, 4f, rect.Height ), 2f );

		var content = rect.Shrink( 16, 0 );

		if ( !string.IsNullOrEmpty( Icon ) )
		{
			Paint.SetPen( SuperShotUI.Accent );
			Paint.DrawIcon( new Rect( content.Left, content.Top, 32, content.Height ), Icon, 26, TextFlag.LeftCenter );
			content.Left += 44;
		}

		Paint.SetDefaultFont( 11, 600 );
		Paint.SetPen( Theme.Text );
		Paint.DrawText( new Rect( content.Left, content.Top + 8, content.Width, 22 ), TitleText, TextFlag.LeftTop );

		if ( !string.IsNullOrEmpty( SubtitleText ) )
		{
			Paint.SetDefaultFont( 8, 400 );
			Paint.SetPen( SuperShotUI.Muted );
			Paint.DrawText( new Rect( content.Left, content.Top + 30, content.Width, 18 ), SubtitleText, TextFlag.LeftTop );
		}
	}
}

public sealed class Card : Widget
{
	public Layout Body { get; private set; }

	public Card( string title = null, string icon = null ) : base( null )
	{
		Layout = Layout.Column();
		Layout.Margin = 12;
		Layout.Spacing = 8;

		if ( !string.IsNullOrEmpty( title ) )
		{
			var header = Layout.AddRow();
			header.Spacing = 6;

			if ( !string.IsNullOrEmpty( icon ) )
				header.Add( new IconLabel( icon ) );

			header.Add( new Label.Subtitle( title ) );
			header.AddStretchCell();
		}

		var bodyWidget = new Widget( this );
		bodyWidget.Layout = Layout.Column();
		bodyWidget.Layout.Spacing = 6;
		Body = bodyWidget.Layout;
		Layout.Add( bodyWidget );
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( SuperShotUI.CardBackground );
		Paint.DrawRect( LocalRect, 6f );

		Paint.ClearBrush();
		Paint.SetPen( SuperShotUI.CardBorder, 1f );
		Paint.DrawRect( LocalRect.Shrink( 0.5f ), 6f );
	}
}

public sealed class IconLabel : Widget
{
	public string Icon { get; set; }

	public IconLabel( string icon ) : base( null )
	{
		Icon = icon;
		FixedSize = new Vector2( 20, 20 );
	}

	protected override void OnPaint()
	{
		Paint.SetPen( SuperShotUI.Accent );
		Paint.DrawIcon( LocalRect, Icon, 18, TextFlag.Center );
	}
}
namespace Editor.ShaderGraphExtras;

public class TextureNodeType : ClassNodeType
{
	string ImagePath;

	public TextureNodeType( TypeDescription type, string imagePath ) : base( type )
	{
		ImagePath = imagePath;
	}
	public override INode CreateNode( IGraph graph )
	{
		var node = base.CreateNode( graph );
		if ( node is ITextureParameterNode textureNode )
		{
			textureNode.Image = ImagePath;
		}
		return node;
	}
}

public class ClassNodeType : INodeType
{
	public virtual string Identifier => Type.FullName;

	public TypeDescription Type { get; }
	public DisplayInfo DisplayInfo { get; protected set; }

	public Menu.PathElement[] Path => Menu.GetSplitPath( DisplayInfo );

	public ClassNodeType( TypeDescription type )
	{
		Type = type;
		if ( Type is not null )
			DisplayInfo = DisplayInfo.ForType( Type.TargetType );
		else
			DisplayInfo = new DisplayInfo();
	}

	public bool TryGetInput( Type valueType, out string name )
	{
		var property = Type.Properties
			.Select( x => (Property: x, Attrib: x.GetCustomAttribute<BaseNode.InputAttribute>()) )
			.Where( x => x.Attrib != null )
			.FirstOrDefault( x => x.Attrib.Type?.IsAssignableFrom( valueType ) ?? true )
			.Property;

		name = property?.Name;
		return name is not null;
	}

	public bool TryGetOutput( Type valueType, out string name )
	{
		var property = Type.Properties
			.Select( x => (Property: x, Attrib: x.GetCustomAttribute<BaseNode.OutputAttribute>()) )
			.Where( x => x.Attrib != null )
			.FirstOrDefault( x => x.Attrib.Type?.IsAssignableTo( valueType ) ?? true )
			.Property;

		name = property?.Name;
		return name is not null;
	}

	public virtual INode CreateNode( IGraph graph )
	{
		var node = Type.Create<BaseNode>();

		node.Graph = graph;

		return node;
	}
}

public class SubgraphNodeType : ClassNodeType
{
	public override string Identifier => AssetPath;
	string AssetPath { get; }

	public SubgraphNodeType( string assetPath, TypeDescription type ) : base( type )
	{
		AssetPath = assetPath;
	}

	public void SetDisplayInfo( ShaderGraph subgraph )
	{
		var info = DisplayInfo;
		if ( !string.IsNullOrEmpty( subgraph.Title ) )
			info.Name = subgraph.Title;
		else
			info.Name = System.IO.Path.GetFileNameWithoutExtension( AssetPath );
		if ( !string.IsNullOrEmpty( subgraph.Description ) )
			info.Description = subgraph.Description;
		if ( !string.IsNullOrEmpty( subgraph.Icon ) )
			info.Icon = subgraph.Icon;
		if ( !string.IsNullOrEmpty( subgraph.Category ) )
			info.Group = subgraph.Category;
		DisplayInfo = info;
	}

	public override INode CreateNode( IGraph graph )
	{
		var node = base.CreateNode( graph );

		if ( node is SubgraphNode subgraphNode )
		{
			subgraphNode.SubgraphPath = AssetPath;
			subgraphNode.OnNodeCreated();
		}

		return node;
	}
}
namespace Editor.ShaderGraphExtras;

public static class ShaderTemplate
{
	// Cache for template types to avoid expensive reflection lookups
	private static readonly Dictionary<string, Type> _templateTypeCache = new();

	public static string LoadTemplate( string templatePath )
	{
		if ( string.IsNullOrWhiteSpace( templatePath ) )
			return SurfaceTemplate.Code;

		var templateType = FindTemplateType( templatePath );
		if ( templateType != null )
		{
			var property = templateType.GetProperty( "Code", BindingFlags.Public | BindingFlags.Static );
			if ( property != null && property.PropertyType == typeof( string ) )
			{
				var templateCode = (string)property.GetValue( null );
				if ( !string.IsNullOrWhiteSpace( templateCode ) )
				{
					return templateCode;
				}
			}
		}

		Log.Warning( $"Shader template at '{templatePath}' not found, using default template" );
		return SurfaceTemplate.Code;
	}

	/// <summary>
	/// Find a template type by searching for classes with a Code property in the Editor.ShaderGraph namespace
	/// </summary>
	private static Type FindTemplateType( string templatePath )
	{
		if ( string.IsNullOrWhiteSpace( templatePath ) )
			return null;

		// Check cache first
		if ( _templateTypeCache.TryGetValue( templatePath, out var cachedType ) )
			return cachedType;

		// Extract class name from file path as a hint
		string className = Path.GetFileNameWithoutExtension( templatePath );

		string[] searchNamespaces = [ "Editor.ShaderGraphExtras" ];

		try
		{
			foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
			{
				try
				{
					// First try exact class name match in known namespaces
					foreach ( var ns in searchNamespaces )
					{
						var templateType = assembly.GetType( $"{ns}.{className}" );
						if ( templateType != null && HasCodeProperty( templateType ) )
						{
							_templateTypeCache[templatePath] = templateType;
							return templateType;
						}
					}

					// Search types in known namespaces for one whose name contains the filename
					foreach ( var type in assembly.GetTypes() )
					{
						if ( !type.IsClass || !type.IsPublic || !HasCodeProperty( type ) )
							continue;

						if ( searchNamespaces.Contains( type.Namespace ) &&
							type.Name.Contains( className, StringComparison.OrdinalIgnoreCase ) )
						{
							_templateTypeCache[templatePath] = type;
							return type;
						}
					}

					// Fallback: search all types for exact class name match
					foreach ( var type in assembly.GetTypes() )
					{
						if ( type.Name == className && HasCodeProperty( type ) )
						{
							_templateTypeCache[templatePath] = type;
							return type;
						}
					}
				}
				catch { continue; }
			}
		}
		catch { }

		// Cache null result to avoid repeated failed lookups
		_templateTypeCache[templatePath] = null;
		return null;
	}

	/// <summary>
	/// Check if a type has a static Code property that returns a string
	/// </summary>
	private static bool HasCodeProperty( Type type )
	{
		var property = type.GetProperty( "Code", BindingFlags.Public | BindingFlags.Static );
		return property != null && property.PropertyType == typeof( string );
	}

	public static Dictionary<string, bool> GetTemplateFeatures( string templatePath )
	{
		var features = new Dictionary<string, bool>();

		if ( string.IsNullOrWhiteSpace( templatePath ) )
			return features;

		var templateType = FindTemplateType( templatePath );
		if ( templateType != null )
		{
			var property = templateType.GetProperty( "Features", BindingFlags.Public | BindingFlags.Static );
			if ( property != null && property.PropertyType == typeof( Dictionary<string, bool> ) )
			{
				var templateFeatures = (Dictionary<string, bool>)property.GetValue( null );
				if ( templateFeatures != null )
				{
					return templateFeatures;
				}
			}
		}

		return features;
	}

	public static Dictionary<string, bool> GetShadingModelFeatures( string shadingModelPath )
	{
		var features = new Dictionary<string, bool>();

		if ( string.IsNullOrWhiteSpace( shadingModelPath ) )
			return features;

		var shadingModelType = FindTemplateType( shadingModelPath );
		if ( shadingModelType != null )
		{
			var property = shadingModelType.GetProperty( "Features", BindingFlags.Public | BindingFlags.Static );
			if ( property != null && property.PropertyType == typeof( Dictionary<string, bool> ) )
			{
				var shadingModelFeatures = (Dictionary<string, bool>)property.GetValue( null );
				if ( shadingModelFeatures != null )
				{
					return shadingModelFeatures;
				}
			}
		}

		return features;
	}

	/// <summary>
	/// Load a custom shading model from a class with static Code and Include properties.
	/// Code: The pixel shader return statement (e.g., "return ShadingModelToon::Shade( m );")
	/// Include: Optional HLSL include path for the shading model implementation
	/// </summary>
	public static (string Code, string Include) LoadShadingModel( string shadingModelPath )
	{
		if ( string.IsNullOrWhiteSpace( shadingModelPath ) )
			return ("return ShadingModelStandard::Shade( m );", null);

		var shadingModelType = FindTemplateType( shadingModelPath );
		if ( shadingModelType != null )
		{
			var codeProperty = shadingModelType.GetProperty( "Code", BindingFlags.Public | BindingFlags.Static );
			if ( codeProperty != null && codeProperty.PropertyType == typeof( string ) )
			{
				var shadingModelCode = (string)codeProperty.GetValue( null );
				if ( !string.IsNullOrWhiteSpace( shadingModelCode ) )
				{
					// Check for optional Include property
					string includePath = null;
					var includeProperty = shadingModelType.GetProperty( "Include", BindingFlags.Public | BindingFlags.Static );
					if ( includeProperty != null && includeProperty.PropertyType == typeof( string ) )
					{
						includePath = (string)includeProperty.GetValue( null );
					}

					return (shadingModelCode, includePath);
				}
			}
		}

		Log.Warning( $"Shading model at '{shadingModelPath}' not found, using default Lit shading model" );
		return ("return ShadingModelStandard::Shade( m );", null);
	}

	[Function( "ColorBurn_blend" )]
	public static string ColorBurn_blend => @"
float ColorBurn_blend( float a, float b )
{
    if ( a >= 1.0f ) return 1.0f;
    if ( b <= 0.0f ) return 0.0f;
    return 1.0f - saturate( ( 1.0f - a ) / b );
}

float3 ColorBurn_blend( float3 a, float3 b )
{
    return float3(
        ColorBurn_blend( a.r, b.r ),
        ColorBurn_blend( a.g, b.g ),
        ColorBurn_blend( a.b, b.b )
	);
}

float4 ColorBurn_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        ColorBurn_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? ColorBurn_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "LinearBurn_blend" )]
	public static string LinearBurn_blend => @"
float LinearBurn_blend( float a, float b )
{
    return max( 0.0f, a + b - 1.0f );
}

float3 LinearBurn_blend( float3 a, float3 b )
{
    return float3(
        LinearBurn_blend( a.r, b.r ),
        LinearBurn_blend( a.g, b.g ),
        LinearBurn_blend( a.b, b.b )
	);
}

float4 LinearBurn_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        LinearBurn_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? LinearBurn_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "ColorDodge_blend" )]
	public static string ColorDodge_blend => @"
float ColorDodge_blend( float a, float b )
{
    if ( a <= 0.0f ) return 0.0f;
    if ( b >= 1.0f ) return 1.0f;
    return saturate( a / ( 1.0f - b ) );
}

float3 ColorDodge_blend( float3 a, float3 b )
{
    return float3(
        ColorDodge_blend( a.r, b.r ),
        ColorDodge_blend( a.g, b.g ),
        ColorDodge_blend( a.b, b.b )
	);
}

float4 ColorDodge_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        ColorDodge_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? ColorDodge_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "LinearDodge_blend" )]
	public static string LinearDodge_blend => @"
float LinearDodge_blend( float a, float b )
{
    return min( 1.0f, a + b );
}

float3 LinearDodge_blend( float3 a, float3 b )
{
    return float3(
        LinearDodge_blend( a.r, b.r ),
        LinearDodge_blend( a.g, b.g ),
        LinearDodge_blend( a.b, b.b )
	);
}

float4 LinearDodge_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        LinearDodge_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? LinearDodge_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "Overlay_blend" )]
	public static string Overlay_blend => @"
float Overlay_blend( float a, float b )
{
    if ( a <= 0.5f )
        return 2.0f * a * b;
    else
        return 1.0f - 2.0f * ( 1.0f - a ) * ( 1.0f - b );
}

float3 Overlay_blend( float3 a, float3 b )
{
    return float3(
        Overlay_blend( a.r, b.r ),
        Overlay_blend( a.g, b.g ),
        Overlay_blend( a.b, b.b )
	);
}

float4 Overlay_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        Overlay_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? Overlay_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "SoftLight_blend" )]
	public static string SoftLight_blend => @"
float SoftLight_blend( float a, float b )
{
    if ( b <= 0.5f )
        return 2.0f * a * b + a * a * ( 1.0f * 2.0f * b );
    else 
        return sqrt( a ) * ( 2.0f * b - 1.0f ) + 2.0f * a * (1.0f - b);
}

float3 SoftLight_blend( float3 a, float3 b )
{
    return float3(
        SoftLight_blend( a.r, b.r ),
        SoftLight_blend( a.g, b.g ),
        SoftLight_blend( a.b, b.b )
	);
}

float4 SoftLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        SoftLight_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? SoftLight_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "HardLight_blend" )]
	public static string HardLight_blend => @"
float HardLight_blend( float a, float b )
{
    if(b <= 0.5f)
        return 2.0f * a * b;
    else
        return 1.0f - 2.0f * (1.0f - a) * (1.0f - b);
}

float3 HardLight_blend( float3 a, float3 b )
{
    return float3(
        HardLight_blend( a.r, b.r ),
        HardLight_blend( a.g, b.g ),
        HardLight_blend( a.b, b.b )
	);
}

float4 HardLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        HardLight_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? HardLight_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "VividLight_blend" )]
	public static string VividLight_blend => @"
float VividLight_blend( float a, float b )
{
    if ( b <= 0.5f )
	{
		b *= 2.0f;
		if ( a >= 1.0f ) return 1.0f;
		if ( b <= 0.0f ) return 0.0f;
		return 1.0f - saturate( ( 1.0f - a ) / b );
	}
    else
	{
		b = 2.0f * ( b - 0.5f );
		if ( a <= 0.0f ) return 0.0f;
		if ( b >= 1.0f ) return 1.0f;
		return saturate( a / ( 1.0f - b ) );
	}
}

float3 VividLight_blend( float3 a, float3 b )
{
    return float3(
        VividLight_blend( a.r, b.r ),
        VividLight_blend( a.g, b.g ),
        VividLight_blend( a.b, b.b )
	);
}

float4 VividLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        VividLight_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? VividLight_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "LinearLight_blend" )]
	public static string LinearLight_blend => @"
float LinearLight_blend( float a, float b )
{
    if ( b <= 0.5f )
	{
		b *= 2.0f;
		return max( 0.0f, a + b - 1.0f );
	}
    else
	{
		b = 2.0f * ( b - 0.5f );
		return min( 1.0f, a + b );
	}
}

float3 LinearLight_blend( float3 a, float3 b )
{
    return float3(
        LinearLight_blend( a.r, b.r ),
        LinearLight_blend( a.g, b.g ),
        LinearLight_blend( a.b, b.b )
	);
}

float4 LinearLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        LinearLight_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? LinearLight_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "HardMix_blend" )]
	public static string HardMix_blend => @"
float HardMix_blend( float a, float b )
{
    if(a + b >= 1.0f) return 1.0f;
    else return 0.0f;
}

float3 HardMix_blend( float3 a, float3 b )
{
    return float3(
        HardMix_blend( a.r, b.r ),
        HardMix_blend( a.g, b.g ),
        HardMix_blend( a.b, b.b )
	);
}

float4 HardMix_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        HardMix_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? HardMix_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "Divide_blend" )]
	public static string Divide_blend => @"
float Divide_blend( float a, float b )
{
    if( b > 0.0f )
        return saturate( a / b );
    else
        return 0.0f;
}

float3 Divide_blend( float3 a, float3 b )
{
    return float3(
        Divide_blend( a.r, b.r ),
        Divide_blend( a.g, b.g ),
        Divide_blend( a.b, b.b )
	);
}

float4 Divide_blend( float4 a, float4 b, bool blendAlpha = false )
{
    return float4(
        Divide_blend( a.rgb, b.rgb ).rgb,
        blendAlpha ? Divide_blend( a.a, b.a ) : max( a.a, b.a )
    );
}
";

	[Function( "RGB2HSV" )]
	public static string RGB2HSV => @"
float3 RGB2HSV( float3 c )
{
    float4 K = float4( 0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0 );
    float4 p = lerp( float4( c.bg, K.wz ), float4( c.gb, K.xy ), step( c.b, c.g ) );
    float4 q = lerp( float4( p.xyw, c.r ), float4( c.r, p.yzx ), step( p.x, c.r ) );

    float d = q.x - min( q.w, q.y );
    float e = 1.0e-10;
    return float3( abs( q.z + ( q.w - q.y ) / ( 6.0 * d + e ) ), d / ( q.x + e ), q.x );
}
";

	[Function( "HSV2RGB" )]
	public static string HSV2RGB => @"
float3 HSV2RGB( float3 c )
{
    float4 K = float4( 1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0 );
    float3 p = abs( frac( c.xxx + K.xyz ) * 6.0 - K.www );
    return c.z * lerp( K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y );
}
";

	[Function( "TexTriplanar_Color" )]
	public static string TexTriplanar_Color => @"
float4 TexTriplanar_Color( in Texture2D tTex, in SamplerState sSampler, float3 vPosition, float3 vNormal )
{
	float2 uvX = vPosition.zy;
	float2 uvY = vPosition.xz;
	float2 uvZ = vPosition.xy;

	float3 triblend = saturate(pow(abs(vNormal), 4));
	triblend /= max(dot(triblend, half3(1,1,1)), 0.0001);

	half3 axisSign = vNormal < 0 ? -1 : 1;

	uvX.x *= axisSign.x;
	uvY.x *= axisSign.y;
	uvZ.x *= -axisSign.z;

	float4 colX = Tex2DS( tTex, sSampler, uvX );
	float4 colY = Tex2DS( tTex, sSampler, uvY );
	float4 colZ = Tex2DS( tTex, sSampler, uvZ );

	return colX * triblend.x + colY * triblend.y + colZ * triblend.z;
}
";

	[Function( "TexTriplanar_Normal" )]
	public static string TexTriplanar_Normal => @"
float3 TexTriplanar_Normal( in Texture2D tTex, in SamplerState sSampler, float3 vPosition, float3 vNormal )
{
	float2 uvX = vPosition.zy;
	float2 uvY = vPosition.xz;
	float2 uvZ = vPosition.xy;

	float3 triblend = saturate( pow( abs( vNormal ), 4 ) );
	triblend /= max( dot( triblend, half3( 1, 1, 1 ) ), 0.0001 );

	half3 axisSign = vNormal < 0 ? -1 : 1;

	uvX.x *= axisSign.x;
	uvY.x *= axisSign.y;
	uvZ.x *= -axisSign.z;

	float3 tnormalX = DecodeNormal( Tex2DS( tTex, sSampler, uvX ).xyz );
	float3 tnormalY = DecodeNormal( Tex2DS( tTex, sSampler, uvY ).xyz );
	float3 tnormalZ = DecodeNormal( Tex2DS( tTex, sSampler, uvZ ).xyz );

	tnormalX.x *= axisSign.x;
	tnormalY.x *= axisSign.y;
	tnormalZ.x *= -axisSign.z;

	tnormalX = half3( tnormalX.xy + vNormal.zy, vNormal.x );
	tnormalY = half3( tnormalY.xy + vNormal.xz, vNormal.y );
	tnormalZ = half3( tnormalZ.xy + vNormal.xy, vNormal.z );

	return normalize(
		tnormalX.zyx * triblend.x +
		tnormalY.xzy * triblend.y +
		tnormalZ.xyz * triblend.z +
		vNormal
	);
}
";
	[Function( "Quaternion_FromAngles" )]
	public static string Quaternion_FromAngles => @"
float4 Quaternion_FromAngles( float3 vAngles )
{
	float4 rot = { 0.0, 0.0, 0.0, 1.0 };

	const float ANGLE_CONVERSION = 3.14159265 / 360.0;

	float pitch = vAngles.x * ANGLE_CONVERSION;
	float yaw = vAngles.y * ANGLE_CONVERSION;
	float roll = vAngles.z * ANGLE_CONVERSION;

	float sp = sin( pitch );
	float cp = cos( pitch );

	float sy = sin( yaw );
	float cy = cos( yaw );

	float sr = sin( roll );
	float cr = cos( roll );

	float srXcp = sr * cp;
	float crXsp = cr * sp;

	rot.x = srXcp * cy - crXsp * sy; // X
	rot.y = crXsp * cy + srXcp * sy; // Y

	float crXcp = cr * cp;
	float srXsp = sr * sp;

	rot.z = crXcp * sy - srXsp * cy; // Z
	rot.w = crXcp * cy + srXsp * sy; // W (real component)

	return rot;
}
";

	[Function( "Matrix_Identity" )]
	public static string Matrix_Identity => @"
float4x4 Matrix_Identity()
{
	return
	{
		1.0, 0.0, 0.0, 0.0,
		0.0, 1.0, 0.0, 0.0,
		0.0, 0.0, 1.0, 0.0,
		0.0, 0.0, 0.0, 1.0
	};
}
";

	[Function( "Matrix_FromQuaternion" )]
	public static string Matrix_FromQuaternion => @"
float4x4 Matrix_FromQuaternion( float4 qRotation )
{
	float xx = qRotation.x * qRotation.x;
	float yy = qRotation.y * qRotation.y;
	float zz = qRotation.z * qRotation.z;

	float xy = qRotation.x * qRotation.y;
	float wz = qRotation.z * qRotation.w;
	float xz = qRotation.z * qRotation.x;
	float wy = qRotation.y * qRotation.w;
	float yz = qRotation.y * qRotation.z;
	float wx = qRotation.x * qRotation.w;

	float4x4 result =
	{
		1.0, 0.0, 0.0, 0.0,
		0.0, 1.0, 0.0, 0.0,
		0.0, 0.0, 1.0, 0.0,
		0.0, 0.0, 0.0, 1.0
	};

	result._11 = 1.0 - 2.0 * (yy + zz);
	result._21 = 2.0 * (xy + wz);
	result._31 = 2.0 * (xz - wy);

	result._12 = 2.0 * (xy - wz);
	result._22 = 1.0 - 2.0 * (zz + xx);
	result._32 = 2.0 * (yz + wx);

	result._13 = 2.0 * (xz + wy);
	result._23 = 2.0 * (yz - wx);
	result._33 = 1.0 - 2.0 * (yy + xx);

	return result;
}
";

	[Function( "Matrix_FromScale" )]
	public static string Matrix_FromScale => @"
float4x4 Matrix_FromScale( float3 vScale )
{
	float4x4 result =
	{
		1.0, 0.0, 0.0, 0.0,
		0.0, 1.0, 0.0, 0.0,
		0.0, 0.0, 1.0, 0.0,
		0.0, 0.0, 0.0, 1.0
	};

	result._11 = vScale.x;
	result._22 = vScale.y;
	result._33 = vScale.z;

	return result;
}
";

	[Function( "Matrix_FromTranslation" )]
	public static string Matrix_FromTranslation => @"
float4x4 Matrix_FromTranslation( float3 vTranslation )
{
	float4x4 result =
	{
		1.0, 0.0, 0.0, 0.0,
		0.0, 1.0, 0.0, 0.0,
		0.0, 0.0, 1.0, 0.0,
		0.0, 0.0, 0.0, 1.0
	};

	result._14 = vTranslation.x;
	result._24 = vTranslation.y;
	result._34 = vTranslation.z;

	return result;
}
";

	[Function( "Vec3OsToTs" )]
	public static string Vec3OsToTs => @"
float3 Vec3OsToTs( float3 vVectorOs, float3 vNormalOs, float3 vTangentUOs, float3 vTangentVOs )
{
	float3 vVectorTs;
	vVectorTs.x = dot( vVectorOs.xyz, vTangentUOs.xyz );
	vVectorTs.y = dot( vVectorOs.xyz, vTangentVOs.xyz );
	vVectorTs.z = dot( vVectorOs.xyz, vNormalOs.xyz );
	return vVectorTs.xyz;
}
";

	public static string TextureDefinition => @"<!-- dmx encoding keyvalues2_noids 1 format vtex 1 -->
""CDmeVtex""
{{
    ""m_inputTextureArray"" ""element_array"" 
    [
        ""CDmeInputTexture""
        {{
            ""m_name"" ""string"" ""0""
            ""m_fileName"" ""string"" ""{0}""
            ""m_colorSpace"" ""string"" ""{1}""
            ""m_typeString"" ""string"" ""2D""
            ""m_imageProcessorArray"" ""element_array"" 
            [
                ""CDmeImageProcessor""
                {{
                    ""m_algorithm"" ""string"" ""{3}""
                    ""m_stringArg"" ""string"" """"
                    ""m_vFloat4Arg"" ""vector4"" ""0 0 0 0""
                }}
            ]
        }}
    ]
    ""m_outputTypeString"" ""string"" ""2D""
    ""m_outputFormat"" ""string"" ""{2}""
    ""m_textureOutputChannelArray"" ""element_array""
    [
        ""CDmeTextureOutputChannel""
        {{
            ""m_inputTextureArray"" ""string_array""
            [
                ""0""
            ]
            ""m_srcChannels"" ""string"" ""rgba""
            ""m_dstChannels"" ""string"" ""rgba""
            ""m_mipAlgorithm"" ""CDmeImageProcessor""
            {{
                ""m_algorithm"" ""string"" ""Box""
                ""m_stringArg"" ""string"" """"
                ""m_vFloat4Arg"" ""vector4"" ""0 0 0 0""
            }}
            ""m_outputColorSpace"" ""string"" ""{1}""
        }}
    ]
}}";

	[AttributeUsage( AttributeTargets.Property )]
	private class FunctionAttribute : Attribute
	{
		public string Name { get; set; }

		public FunctionAttribute( string name )
		{
			Name = name;
		}
	}

	private static Dictionary<string, string> Functions;

	public static bool TryGetFunction( string name, out string func )
	{
		return Functions.TryGetValue( name, out func );
	}

	public static bool HasFunction( string name )
	{
		return Functions.ContainsKey( name );
	}

	internal static bool RegisterFunction( string name, string code )
	{
		if ( Functions.ContainsKey( name ) )
			return false;
		Functions[name] = code;
		return true;
	}

	static ShaderTemplate()
	{
		CreateFunctions();
	}

	[EditorEvent.Hotload]
	private static void CreateFunctions()
	{
		Functions = new Dictionary<string, string>();
		var properties = typeof( ShaderTemplate ).GetProperties( BindingFlags.Public | BindingFlags.Static );

		foreach ( var property in properties )
		{
			if ( property.PropertyType == typeof( string ) )
			{
				var attr = (FunctionAttribute)Attribute.GetCustomAttribute( property, typeof( FunctionAttribute ) );
				if ( attr != null )
				{
					Functions[attr.Name] = (string)property.GetValue( null );
				}
			}
		}
	}
}
namespace Editor.ShaderGraphExtras;
public static class PostProcessTemplate
{
	public static Dictionary<string, bool> Features => new()
	{
		{ "SupportsAlbedo", true },
		{ "SupportsEmission", false },
		{ "SupportsOpacity", true },
		{ "SupportsNormal", false },
		{ "SupportsRoughness", false },
		{ "SupportsMetalness", false },
		{ "SupportsAmbientOcclusion", false },
		{ "SupportsPositionOffset", false },
		{ "SupportsPixelDepthOffset", false },

		{ "SupportsLitShadingModel", false },
		{ "SupportsUnlitShadingModel", true },
		{ "SupportsCustomShadingModel", false },

		{ "SupportsOpaqueBlendMode", false },
		{ "SupportsMaskedBlendMode", false },
		{ "SupportsTranslucentBlendMode", false },
		{ "SupportsDynamicBlendMode", false },
		{ "SupportsCustomBlendMode", true }
	};
	public static string Code => @"
HEADER
{{
	Description = ""{0}"";
}}

FEATURES
{{
	#include ""common/features.hlsl""
{1}
}}

MODES
{{
	Forward();
	Depth();
	ToolsShadingComplexity( ""tools_shading_complexity.shader"" );
}}

COMMON
{{
{2}
	#include ""common/shared.hlsl""
	#include ""procedural.hlsl""

	#define S_UV2 1
}}

struct VertexInput
{{
	#include ""common/vertexinput.hlsl""
	float4 vColor : COLOR0 < Semantic( Color ); >;
{3}
}};

struct PixelInput
{{
	#include ""common/pixelinput.hlsl""
	float3 vPositionOs : TEXCOORD14;
	float3 vNormalOs : TEXCOORD15;
	float4 vTangentUOs_flTangentVSign : TANGENT	< Semantic( TangentU_SignV ); >;
	float4 vColor : COLOR0;
	float4 vTintColor : COLOR1;
	#if ( PROGRAM == VFX_PROGRAM_PS )
		bool vFrontFacing : SV_IsFrontFace;
	#endif
{4}
}};

VS
{{
	#include ""common/vertex.hlsl""
{5}{6}{7}
	PixelInput MainVs( VertexInput v )
	{{

		PixelInput i;
		i.vPositionPs = float4(v.vPositionOs.xy, 0.0f, 1.0f );
		i.vPositionWs = float3(v.vTexCoord, 0.0f);
{8}					
		return i;
	}}
}}

PS
{{
	#include ""common/pixel.hlsl""
	#include ""postprocess/functions.hlsl""
	#include ""postprocess/common.hlsl""

{9}{10}{11}

	Texture2D g_tColorBuffer < Attribute( ""ColorBuffer"" ); SrgbRead ( true ); >;

	float4 MainPs( PixelInput i ) : SV_Target0
	{{
		Material m = Material::Init( i );
		m.Albedo = float3( 1, 1, 1 );
		m.Opacity = 1;
{12}
		m.Opacity = saturate( m.Opacity );
{13}

	}}
}}
";
}
namespace Editor.ShaderGraphExtras;

public struct NodeInput : IValid
{
	[Hide, Browsable( false )]
	public string Identifier { get; set; }

	[Hide, Browsable( false )]
	public string Output { get; set; }

	[Hide, Browsable( false )]
	[JsonIgnore]
	public string Subgraph { get; set; }

	[Hide, Browsable( false )]
	[JsonIgnore]
	public string SubgraphNode { get; set; }

	[Browsable( false )]
	[JsonIgnore, Hide]
	public readonly bool IsValid => !string.IsNullOrWhiteSpace( Identifier ) && !string.IsNullOrWhiteSpace( Output );

	public override readonly string ToString()
	{
		var subgraph = (Subgraph is not null) ? ("." + Subgraph) : "";
		var subgraphNode = (SubgraphNode is not null) ? ("." + SubgraphNode) : "";
		return IsValid ? $"{Identifier}.{Output}{subgraph}{subgraphNode}" : "null";
	}

	public NodeInput()
	{
		Identifier = "";
		Output = "";
		Subgraph = null;
	}

	public static bool operator ==( NodeInput a, NodeInput b ) => a.Identifier == b.Identifier && a.Output == b.Output && a.Subgraph == b.Subgraph && a.SubgraphNode == b.SubgraphNode;
	public static bool operator !=( NodeInput a, NodeInput b ) => a.Identifier != b.Identifier || a.Output != b.Output || a.Subgraph != b.Subgraph || a.SubgraphNode != b.SubgraphNode;
	public override bool Equals( object obj ) => obj is NodeInput input && this == input;
	public override int GetHashCode() => System.HashCode.Combine( Identifier, Output, Subgraph, SubgraphNode );
}

namespace Editor.ShaderGraphExtras.Nodes;
/// <summary>
/// Current time
/// </summary>
[Title( "Time" ), Category( "Variables" ), Icon( "timer" )]
public sealed class Time : ShaderNode
{
	[JsonIgnore]
	public float Value => RealTime.Now;

	[Output( typeof( float ) ), Title( "Time" )]
	[Hide]
	public NodeResult.Func Result => ( GraphCompiler compiler ) =>
	{
		return new NodeResult( 1, compiler.IsPreview ? "g_flPreviewTime" : "g_flTime", compiler.IsNotPreview );
	};
}
namespace Editor.ShaderGraphExtras.Nodes;

[Title("SGE - Noise"), Category( "Shader Graph Extras - Universal" ), Icon("grain")]
public sealed class SGENoiseNode : ShaderNode
{
	public enum SGENoiseMode
	{
		Static,
		Value,
		Simplex,
		[Title("fBM")]
		fBM,
		Voronoi
	}

	public SGENoiseMode Mode { get; set; } = SGENoiseMode.fBM;

	public enum SGENoiseDimension
	{
		[Title("2D")]
		Noise2D,
		[Title("3D")]
		Noise3D
	}

	public SGENoiseDimension Dimension { get; set; } = SGENoiseDimension.Noise2D;

	[Hide, JsonIgnore]
	int _lastHashCode = 0;

	public override void OnFrame()
	{
		base.OnFrame();

		var hashCode = new HashCode();
		hashCode.Add(Mode);
		hashCode.Add(Dimension);
		var hc = hashCode.ToHashCode();
		if (hc != _lastHashCode)
		{
			_lastHashCode = hc;
			CreateInputs();
			Update();
		}
	}

	private void CreateInputs()
	{
		var plugs = new List<IPlugIn>();
		var serialized = this.GetSerialized();
		foreach (var property in serialized)
		{
			if (property.TryGetAttribute<InputAttribute>(out var inputAttr))
			{
				if (property.TryGetAttribute<ConditionalVisibilityAttribute>(out var conditionalVisibilityAttr))
				{
					if (conditionalVisibilityAttr.TestCondition(this.GetSerialized()))
					{
						continue;
					}
				}
				var propertyInfo = typeof(SGENoiseNode).GetProperty(property.Name);
				if (propertyInfo is null) continue;
				var info = new PlugInfo(propertyInfo);
				var displayInfo = info.DisplayInfo;
				displayInfo.Name = property.DisplayName;
				info.DisplayInfo = displayInfo;

				// Try to find existing plug to preserve connections
				var oldPlug = Inputs.FirstOrDefault(x => x is BasePlugIn plugIn && plugIn.Info.Name == property.Name) as BasePlugIn;
				if (oldPlug is not null)
				{
					oldPlug.Info.Name = info.Name;
					oldPlug.Info.Type = info.Type;
					oldPlug.Info.DisplayInfo = info.DisplayInfo;
					plugs.Add(oldPlug);
				}
				else
				{
					var plug = new BasePlugIn(this, info, info.Type);
					plugs.Add(plug);
				}
			}
		}
		Inputs = plugs;
	}

	[Hide]
	private bool IsNoisefBMMode => Mode == SGENoiseMode.fBM;
	[Hide]
	private bool IsNoiseVoronoiMode => Mode == SGENoiseMode.Voronoi;

	[Hide]
	[Input(typeof(Vector3))]
	public NodeInput Coordinates { get; set; }

	[Hide]
	[Input(typeof(int))]
	[ShowIf(nameof(IsNoisefBMMode), true)]
	public NodeInput Octaves { get; set; }

	[InputDefault(nameof(Octaves))]
	[ShowIf(nameof(IsNoisefBMMode), true)]
	public float DefaultOctaves { get; set; } = 6;

	[Hide]
	[Input(typeof(float))]
	[ShowIf(nameof(IsNoiseVoronoiMode), true)]
	public NodeInput Offset { get; set; }

	[InputDefault(nameof(Offset))]
	[ShowIf(nameof(IsNoiseVoronoiMode), true)]
	public float DefaultOffset { get; set; } = 3.14159265359f;

	[Hide]
	[Output(typeof(float))]
	public NodeResult.Func Output => (GraphCompiler compiler) =>
	{
		var coordinates = compiler.Result(Coordinates);

		compiler.RegisterInclude("shaders/HLSL/Functions/FUNC-noise.hlsl");

		NodeResult result = new NodeResult();

		switch (Mode)
		{
			case SGENoiseMode.Static:
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEStaticNoise2D({coordinates})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEStaticNoise3D({coordinates})");
				}
				break;

			case SGENoiseMode.Value:
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEValueNoise2D({coordinates})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEValueNoise3D({coordinates})");
				}
				break;

			case SGENoiseMode.Simplex:
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGESimplexNoise2D({coordinates})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGESimplexNoise3D({coordinates})");
				}
				break;

			case SGENoiseMode.fBM:
				var octaves = compiler.ResultOrDefault(Octaves, DefaultOctaves);

				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEfBMNoise2D({coordinates}, {octaves})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEfBMNoise3D({coordinates}, {octaves})");
				}
				break;

			case SGENoiseMode.Voronoi:
				var offset = compiler.ResultOrDefault(Offset,DefaultOffset);
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEVoronoiNoise2D({coordinates}, {offset})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEVoronoiNoise3D({coordinates}, {offset})");
				}
				break;
		}

		return result;
	};
}
namespace Editor.ShaderGraphExtras;

internal static class ComboControlWidgetHelper
{
	public static ShaderNode GetShaderNode( SerializedProperty property )
	{
		if ( property is null )
			return null;

		if ( property.Parent.Targets.First() is ShaderNode shaderNode )
			return shaderNode;

		return GetShaderNode( property.Parent?.ParentProperty );
	}

	public static void SetupComboBox<T>(
		ComboBox comboBox,
		SerializedProperty property,
		string currentValue,
		Func<SGEComboNode, string> getValue,
		Func<string, T> createValue )
	{
		List<string> namesSoFar = [currentValue];

		comboBox.AddItem( "" );
		if ( !string.IsNullOrEmpty( currentValue ) )
		{
			comboBox.AddItem( currentValue );
			comboBox.CurrentIndex = 1;
		}

		var parentNode = GetShaderNode( property );
		if ( parentNode is not null )
		{
			foreach ( var node in parentNode.Graph.Nodes )
			{
				if ( node is SGEComboNode comboNode )
				{
					string value = getValue( comboNode );
					if ( !string.IsNullOrEmpty( value ) && !namesSoFar.Contains( value ) )
					{
						comboBox.AddItem( value );
						namesSoFar.Add( value );
					}
				}
			}
		}

		comboBox.Editable = true;
		comboBox.Insertion = ComboBox.InsertMode.Skip;

		comboBox.TextChanged += () =>
		{
			property.SetValue( createValue( comboBox.CurrentText ) );
		};
	}
}

[CustomEditor( typeof( ComboName ) )]
internal class ComboNameControlWidget : ControlWidget
{
	public override bool SupportsMultiEdit => false;

	public ComboNameControlWidget( SerializedProperty property ) : base( property )
	{
		Layout = Layout.Row();
		var comboBox = Layout.Add( new ComboBox( this ) );
		var currentValue = SerializedProperty.GetValue<ComboName>().Name;

		ComboControlWidgetHelper.SetupComboBox(
			comboBox, property, currentValue,
			node => node.Name,
			text => new ComboName { Name = text } );
	}
}

[CustomEditor( typeof( ComboGroup ) )]
internal class ComboGroupControlWidget : ControlWidget
{
	public override bool SupportsMultiEdit => false;

	public ComboGroupControlWidget( SerializedProperty property ) : base( property )
	{
		Layout = Layout.Row();
		var comboBox = Layout.Add( new ComboBox( this ) );
		var currentValue = SerializedProperty.GetValue<ComboGroup>().Group;

		ComboControlWidgetHelper.SetupComboBox(
			comboBox, property, currentValue,
			node => node.Group,
			text => new ComboGroup { Group = text } );
	}
}
namespace Editor.ShaderGraphExtras;

[CustomEditor( typeof( string ), NamedEditor = "shadergraphgroup" )]
internal class ShaderGraphGroupControlWidget : ControlWidget
{
	public override bool SupportsMultiEdit => false;

	ComboBox _comboBox;

	public ShaderGraphGroupControlWidget( SerializedProperty property ) : base( property )
	{
		Layout = Layout.Row();

		_comboBox = Layout.Add( new ComboBox( this ) );

		var currentVal = SerializedProperty.GetValue<string>();
		List<string> namesSoFar = [currentVal];

		_comboBox.AddItem( "" );
		if ( !string.IsNullOrEmpty( currentVal ) )
		{
			_comboBox.AddItem( currentVal );
			_comboBox.CurrentIndex = 1;
		}

		var groupProperty = GetGroupProperty( property );
		var parentNode = GetShaderNode( property );
		if ( groupProperty is not null && parentNode is not null )
		{
			foreach ( var node in parentNode.Graph.Nodes )
			{
				var serialized = node.GetSerialized();
				foreach ( var prop in serialized )
				{
					if ( prop.PropertyType == typeof( ParameterUI ) || prop.PropertyType == typeof( TextureInput ) )
					{
						if ( prop.TryGetAsObject( out var propObj ) )
						{
							// Get same property name so groups only show group names, sub-groups only show sub-group names, ect
							var innerProp = propObj.GetProperty( groupProperty?.Name );
							var groupVal = innerProp?.GetValue<UIGroup>();
							if ( !string.IsNullOrEmpty( groupVal?.Name ) && !namesSoFar.Contains( groupVal?.Name ) )
							{
								_comboBox.AddItem( groupVal?.Name );
								namesSoFar.Add( groupVal?.Name );
							}
						}
					}
				}
			}
		}

		_comboBox.Editable = true;
		_comboBox.Insertion = ComboBox.InsertMode.Skip;

		_comboBox.TextChanged += () =>
		{
			SerializedProperty.SetValue<string>( _comboBox.CurrentText );
		};
	}

	SerializedProperty GetGroupProperty( SerializedProperty originalProperty )
	{
		if ( originalProperty is null )
		{
			return null;
		}
		if ( originalProperty.PropertyType == typeof( UIGroup ) )
		{
			return originalProperty;
		}
		return GetGroupProperty( originalProperty.Parent?.ParentProperty );
	}

	ShaderNode GetShaderNode( SerializedProperty originalProperty )
	{
		if ( originalProperty is null )
		{
			return null;
		}
		if ( originalProperty.Parent.Targets.First() is ShaderNode shaderNode )
		{
			return shaderNode;
		}
		return GetShaderNode( originalProperty.Parent?.ParentProperty );
	}
}

namespace Editor.ShaderGraphExtras;

internal class GamePerformanceBar : Widget
{
	private readonly Func<string> _getValue;
	private RealTimeSince _timeSinceUpdate;

	public GamePerformanceBar( Func<string> val ) : base( null )
	{
		_getValue = val;

		MinimumHeight = Theme.RowHeight;
		MinimumWidth = 60;
	}

	protected override void DoLayout()
	{
		base.DoLayout();
	}

	protected override void OnPaint()
	{
		base.OnPaint();

		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( LocalRect, Theme.ControlRadius );

		Paint.SetPen( Theme.Green.WithAlpha( 0.4f ) );
		Paint.DrawText( LocalRect.Shrink( 8, 0 ), _getValue(), TextFlag.RightCenter );
	}

	[EditorEvent.Frame]
	public void Frame()
	{
		if ( _timeSinceUpdate < 0.6f )
			return;

		_timeSinceUpdate = Random.Shared.Float( 0, 0.1f );

		Update();
	}
}
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;

namespace HammerTextureBrowser;

[Dock( "Editor", "OG Texture Browser", "texture", DockArea.Bottom )]
public sealed class HammerTextureBrowserDock : Widget, AssetSystem.IEventListener
{
	private static readonly Regex QuotedMaterialProperty = new( "\"(?<key>[^\"]+)\"\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant );
	private static readonly string[] PrimaryTextureKeys =
	[
		"TextureColor",
		"TextureBaseColor",
		"TextureAlbedo",
		"AlbedoTexture",
		"BaseTexture",
		"BaseColorTexture",
		"g_tColor",
		"g_tBaseColor",
		"g_tAlbedo"
	];

	private readonly HammerTextureList TextureList;
	private readonly ComboBox SizeCombo;
	private readonly LineEdit FilterEdit;
	private readonly ComboBox KeywordsCombo;
	private readonly Checkbox OnlyUsedTextures;
	private readonly Label SelectedTextureLabel;
	private readonly Label TextureSizeLabel;
	private readonly Label CountLabel;
	private readonly Button MarkButton;
	private readonly Button ReplaceButton;
	private readonly Button ReloadButton;
	private readonly Button OpenSourceButton;

	private List<Asset> AllMaterials = new();
	private Asset SelectedMaterial;
	private string KeywordFilter = string.Empty;
	private int PreviewSize = 128;

	public HammerTextureBrowserDock( Widget parent ) : base( parent )
	{
		MinimumSize = new( 420, 320 );
		WindowTitle = "OG Texture Browser";
		SetWindowIcon( "texture" );

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		TextureList = Layout.Add( new HammerTextureList( this ), 1 );
		TextureList.OnTextureSelected = SelectMaterial;
		TextureList.OnTextureActivated = SelectMaterial;
		TextureList.OnOpenInEditor = OpenMaterialSource;
		TextureList.OnOpenInAssetBrowser = OpenInAssetBrowser;

		var bottom = Layout.Add( new Widget( this ) );
		bottom.Layout = Layout.Column();
		bottom.Layout.Margin = 4;
		bottom.Layout.Spacing = 3;
		bottom.FixedHeight = Theme.RowHeight * 2 + 12;
		bottom.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground );
			Paint.DrawRect( bottom.LocalRect );
			return false;
		};

		var topRow = bottom.Layout.AddRow();
		topRow.Spacing = 5;

		var sizeBlock = topRow.Add( new Widget( this ) );
		sizeBlock.FixedWidth = 138;
		sizeBlock.MinimumWidth = 138;
		sizeBlock.MaximumWidth = 138;
		sizeBlock.Layout = Layout.Row();
		sizeBlock.Layout.Margin = 0;
		sizeBlock.Layout.Spacing = 5;

		AddSmallLabel( sizeBlock.Layout, "Size:" );
		SizeCombo = sizeBlock.Layout.Add( new ComboBox( sizeBlock ) );
		SizeCombo.FixedWidth = 88;
		SizeCombo.MinimumWidth = 88;
		SizeCombo.MaximumWidth = 88;
		SizeCombo.AddItem( "32x32", null, () => SetPreviewSize( 32 ) );
		SizeCombo.AddItem( "64x64", null, () => SetPreviewSize( 64 ) );
		SizeCombo.AddItem( "128x128", null, () => SetPreviewSize( 128 ) );
		SizeCombo.AddItem( "256x256", null, () => SetPreviewSize( 256 ) );
		SizeCombo.AddItem( "1:1", null, () => SetPreviewSize( 128 ) );
		SizeCombo.CurrentIndex = 2;
		StyleBottomInput( SizeCombo, 24 );

		AddSmallLabel( topRow, "Filter:", 58 );
		FilterEdit = topRow.Add( new LineEdit( this ) );
		FilterEdit.FixedWidth = 176;
		FilterEdit.MinimumWidth = 176;
		FilterEdit.MaximumWidth = 176;
		FilterEdit.PlaceholderText = "texture name";
		FilterEdit.TextChanged += _ => RefreshList();
		StyleBottomInput( FilterEdit );

		SelectedTextureLabel = topRow.Add( new Label( this ) );
		SelectedTextureLabel.MinimumWidth = 180;
		SelectedTextureLabel.Alignment = TextFlag.LeftCenter;
		SelectedTextureLabel.Text = "";

		OpenSourceButton = topRow.Add( new Button( "Open Source" ) );
		OpenSourceButton.FixedWidth = 96;
		OpenSourceButton.Clicked = OpenSelectedSource;

		var bottomRow = bottom.Layout.AddRow();
		bottomRow.Spacing = 5;
		OnlyUsedTextures = bottomRow.Add( new Checkbox( "Only used textures" ) );
		OnlyUsedTextures.FixedWidth = 138;
		OnlyUsedTextures.StateChanged += _ => RefreshList();

		AddSmallLabel( bottomRow, "Keywords:", 58 );
		KeywordsCombo = bottomRow.Add( new ComboBox( this ) );
		KeywordsCombo.FixedWidth = 176;
		KeywordsCombo.MinimumWidth = 176;
		KeywordsCombo.MaximumWidth = 176;
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
		StyleBottomInput( KeywordsCombo, 24 );

		MarkButton = bottomRow.Add( new Button( "Mark" ) );
		MarkButton.FixedWidth = 76;
		MarkButton.Clicked = MarkSelectedMaterial;

		ReplaceButton = bottomRow.Add( new Button( "Replace" ) );
		ReplaceButton.FixedWidth = 76;
		ReplaceButton.Clicked = ReplaceSelectedMaterial;

		ReloadButton = bottomRow.Add( new Button( "Reload" ) );
		ReloadButton.FixedWidth = 76;
		ReloadButton.Clicked = Reload;

		TextureSizeLabel = bottomRow.Add( new Label( this ) );
		TextureSizeLabel.MinimumWidth = 0;
		TextureSizeLabel.MaximumWidth = 68;
		TextureSizeLabel.Alignment = TextFlag.RightCenter;

		bottomRow.AddStretchCell( 1 );

		CountLabel = bottomRow.Add( new Label( this ) );
		CountLabel.MinimumWidth = 0;
		CountLabel.MaximumWidth = 96;
		CountLabel.Alignment = TextFlag.RightCenter;

		ReloadMaterials();
		SetPreviewSize( PreviewSize );
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	private void Reload()
	{
		ReloadMaterials();
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	void AssetSystem.IEventListener.OnAssetSystemChanges()
	{
		Reload();
	}

	void AssetSystem.IEventListener.OnAssetTagsChanged()
	{
		ReloadMaterials();
		RefreshList();
	}

	void AssetSystem.IEventListener.OnAssetChanged( Asset asset )
	{
		if ( asset?.AssetType != AssetType.Material )
			return;

		ReloadMaterials();
		RefreshList();

		if ( asset == SelectedMaterial )
			UpdateSelectedMaterialUi();
	}

	private static void AddSmallLabel( Layout row, string text, int fixedWidth = 0 )
	{
		var label = row.Add( new Label( text ) );
		label.Alignment = fixedWidth > 0 ? TextFlag.RightCenter : TextFlag.LeftCenter;
		label.FixedWidth = fixedWidth > 0 ? fixedWidth : text.Length * 6 + 4;
	}

	private static void StyleBottomInput( Widget widget, int fixedHeight = 22 )
	{
		widget.FixedHeight = fixedHeight;
		widget.SetStyles(
			"background-color: #2d3136; " +
			"border: 1px solid #555c64; " +
			"border-radius: 2px; " +
			"color: #f2f2f2; " +
			"padding-top: 0px; " +
			"padding-bottom: 0px; " +
			"padding-left: 5px; " +
			"padding-right: 5px;" );
	}

	private void ReloadMaterials()
	{
		var menuPath = EditorUtility.Projects.GetAll()
			.FirstOrDefault( x => x.Config.Ident == "menu" )
			?.GetAssetsPath()
			.NormalizeFilename( false );

		AllMaterials = AssetSystem.All
			.Where( IsBrowsableMaterial )
			.Where( x => !IsMenuAsset( x, menuPath ) )
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.ToList();

		RebuildKeywords();
	}

	private static bool IsBrowsableMaterial( Asset asset )
	{
		if ( asset is null )
			return false;

		if ( asset.AssetType != AssetType.Material )
			return false;

		if ( asset.AbsolutePath?.Contains( ".sbox/cloud/", StringComparison.OrdinalIgnoreCase ) ?? false )
			return false;

		return true;
	}

	private static bool IsMenuAsset( Asset asset, string menuPath )
	{
		if ( string.IsNullOrEmpty( menuPath ) )
			return false;

		return asset.AbsolutePath?.NormalizeFilename( false ).StartsWith( menuPath, StringComparison.OrdinalIgnoreCase ) ?? false;
	}

	private void RebuildKeywords()
	{
		var tags = AllMaterials
			.SelectMany( x => x.Tags )
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
			.ToList();

		KeywordsCombo.Clear();
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );

		foreach ( var tag in tags )
		{
			var tagValue = tag;
			KeywordsCombo.AddItem( tagValue, null, () => SetKeywordFilter( tagValue ) );
		}

		KeywordsCombo.CurrentIndex = 0;
	}

	private void SetKeywordFilter( string tag )
	{
		KeywordFilter = tag ?? string.Empty;
		RefreshList();
	}

	private void SetPreviewSize( int size )
	{
		PreviewSize = size;
		TextureList.SetPreviewSize( PreviewSize );
		RefreshList();
	}

	private void RefreshList()
	{
		if ( TextureList is null )
			return;

		IEnumerable<Asset> materials = AllMaterials;

		var query = FilterEdit?.Text ?? string.Empty;
		if ( !string.IsNullOrWhiteSpace( query ) )
		{
			var parts = query.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
			materials = materials.Where( x => MatchesQuery( x, parts ) );
		}

		if ( !string.IsNullOrWhiteSpace( KeywordFilter ) )
		{
			materials = materials.Where( x => x.Tags.Any( tag => tag.Equals( KeywordFilter, StringComparison.OrdinalIgnoreCase ) ) );
		}

		if ( OnlyUsedTextures?.Value ?? false )
		{
			var used = HammerMaterialSelection.GetUsedMaterialKeys();
			materials = materials.Where( x => used.Contains( x.RelativePath ) || used.Contains( x.AbsolutePath ) || used.Contains( TextureDisplayName( x ) ) );
		}

		var entries = materials
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.Select( x => new HammerTextureEntry( x ) )
			.ToList();

		TextureList.SetItems( entries );
		if ( CountLabel is not null )
		{
			CountLabel.Text = $"{entries.Count:n0} textures";
			CountLabel.Update();
		}

		if ( SelectedMaterial is not null && entries.All( x => x.Asset != SelectedMaterial ) )
			TextureList.UnselectAll();
	}

	private static bool MatchesQuery( Asset asset, IEnumerable<string> parts )
	{
		var name = TextureDisplayName( asset );
		var type = asset.AssetType?.FriendlyName ?? string.Empty;
		IEnumerable<string> tags = asset.Tags;

		foreach ( var part in parts )
		{
			var search = part;
			var negated = false;
			if ( search.StartsWith( "-" ) )
			{
				search = search[1..];
				negated = true;
			}

			var matched =
				name.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				type.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				tags.Any( x => x.Contains( search, StringComparison.OrdinalIgnoreCase ) );

			if ( !negated && !matched )
				return false;

			if ( negated && matched )
				return false;
		}

		return true;
	}

	private void SelectMaterial( Asset material )
	{
		if ( material?.AssetType != AssetType.Material )
			return;

		SelectedMaterial = material;
		EditorUtility.InspectorObject = material;
		HammerMaterialSelection.SetCurrentMaterial( material );
		UpdateSelectedMaterialUi();
	}

	private void UpdateSelectedMaterialUi()
	{
		var hasMaterial = SelectedMaterial is not null;

		SelectedTextureLabel.Text = hasMaterial ? TextureDisplayName( SelectedMaterial ) : "";
		SelectedTextureLabel.ToolTip = SelectedMaterial?.RelativePath ?? string.Empty;

		TextureSizeLabel.Text = GetTextureSizeText( SelectedMaterial );
		TextureSizeLabel.ToolTip = SelectedMaterial?.AbsolutePath ?? string.Empty;

		MarkButton.Enabled = hasMaterial;
		ReplaceButton.Enabled = hasMaterial;
		OpenSourceButton.Enabled = hasMaterial;

		SelectedTextureLabel.Update();
		TextureSizeLabel.Update();
		MarkButton.Update();
		ReplaceButton.Update();
		OpenSourceButton.Update();
	}

	private static string GetTextureSizeText( Asset asset )
	{
		if ( asset is null )
			return "";

		var texturePath = GetPrimaryMaterialTexturePath( asset );
		if ( string.IsNullOrWhiteSpace( texturePath ) )
			return "";

		try
		{
			var texture = Texture.Load( texturePath );
			if ( texture is null || !texture.IsValid() || texture.Width <= 0 || texture.Height <= 0 )
				return "";

			return $"{texture.Width}x{texture.Height}";
		}
		catch
		{
			return "";
		}
	}

	private static string GetPrimaryMaterialTexturePath( Asset asset )
	{
		if ( string.IsNullOrWhiteSpace( asset?.AbsolutePath ) || !File.Exists( asset.AbsolutePath ) )
			return null;

		try
		{
			var properties = QuotedMaterialProperty.Matches( File.ReadAllText( asset.AbsolutePath ) )
				.Select( x => (Key: x.Groups["key"].Value, Value: NormalizeTexturePath( x.Groups["value"].Value )) )
				.Where( x => IsTexturePath( x.Value ) )
				.ToList();

			foreach ( var key in PrimaryTextureKeys )
			{
				var match = properties.FirstOrDefault( x => x.Key.Equals( key, StringComparison.OrdinalIgnoreCase ) );
				if ( !string.IsNullOrWhiteSpace( match.Value ) )
					return match.Value;
			}

			var colorMatch = properties.FirstOrDefault( x =>
				(x.Key.Contains( "color", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "albedo", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "base", StringComparison.OrdinalIgnoreCase )) &&
				!x.Key.Contains( "tint", StringComparison.OrdinalIgnoreCase ) );

			if ( !string.IsNullOrWhiteSpace( colorMatch.Value ) )
				return colorMatch.Value;

			return properties.FirstOrDefault().Value;
		}
		catch
		{
			return null;
		}
	}

	private static string NormalizeTexturePath( string path )
	{
		return path?.Trim().Replace( '\\', '/' );
	}

	private static bool IsTexturePath( string path )
	{
		var extension = Path.GetExtension( path );
		return extension.Equals( ".vtex", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tga", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".png", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpeg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tif", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tiff", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".psd", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".exr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".hdr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".bmp", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".webp", StringComparison.OrdinalIgnoreCase );
	}

	private void MarkSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.SelectFacesUsingMaterial( SelectedMaterial );
	}

	private void ReplaceSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.AssignAssetToSelection( SelectedMaterial );
	}

	private void OpenSelectedSource()
	{
		OpenMaterialSource( SelectedMaterial );
	}

	private void OpenMaterialSource( Asset asset )
	{
		if ( asset is null )
			return;

		if ( asset.CanOpenInEditor )
		{
			asset.OpenInEditor();
			return;
		}

		EditorUtility.OpenFileFolder( asset.AbsolutePath );
	}

	private void OpenInAssetBrowser( Asset asset )
	{
		if ( asset is null )
			return;

		LocalAssetBrowser.OpenTo( asset, true );
	}

	internal static string TextureDisplayName( Asset asset )
	{
		var path = asset?.RelativePath ?? asset?.Name ?? "";
		path = path.NormalizeFilename( false, false );

		if ( path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
			path = path["materials/".Length..];

		var extension = Path.GetExtension( path );
		if ( !string.IsNullOrEmpty( extension ) )
			path = path[..^extension.Length];

		return path;
	}
}

internal sealed class HammerTextureList : ListView, AssetSystem.IEventListener
{
	private int PreviewSize = 128;

	public Action<Asset> OnTextureSelected;
	public Action<Asset> OnTextureActivated;
	public Action<Asset> OnOpenInEditor;
	public Action<Asset> OnOpenInAssetBrowser;

	public HammerTextureList( Widget parent ) : base( parent )
	{
		MultiSelect = false;
		FocusMode = FocusMode.TabOrClick;
		Margin = 2;
		ItemSpacing = 2;
		ItemPaint = PaintTextureItem;
		ItemSelected = item => SelectTexture( item, false );
		ItemActivated = item => SelectTexture( item, true );
		ItemDrag = StartTextureDrag;
		ItemContextMenu = OpenTextureContextMenu;
		ItemScrollEnter = item => (item as HammerTextureEntry)?.OnScrollEnter();
		ItemScrollExit = item => (item as HammerTextureEntry)?.OnScrollExit();
		OnPaintOverride = PaintBackground;
	}

	public void SetPreviewSize( int previewSize )
	{
		PreviewSize = previewSize;
		ItemSize = new Vector2( PreviewSize + 4, PreviewSize + 18 );
		Update();
	}

	public void SetItems( IEnumerable<HammerTextureEntry> entries )
	{
		base.SetItems( entries.Cast<object>() );
		Update();
	}

	void AssetSystem.IEventListener.OnAssetThumbGenerated( Asset asset )
	{
		Rebuild();
	}

	private bool PaintBackground()
	{
		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( LocalRect );
		return false;
	}

	private void SelectTexture( object item, bool activated )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		if ( activated )
			OnTextureActivated?.Invoke( entry.Asset );
		else
			OnTextureSelected?.Invoke( entry.Asset );
	}

	private bool StartTextureDrag( object item )
	{
		if ( item is not HammerTextureEntry entry || entry.Asset is null )
			return false;

		SelectItem( entry );
		SelectTexture( entry, false );

		var drag = new Drag( this );
		drag.Data.Object = entry.Asset;
		drag.Data.Text = entry.Asset.RelativePath;
		drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );

		foreach ( var selected in SelectedItems.OfType<HammerTextureEntry>() )
		{
			if ( selected == entry || selected.Asset is null )
				continue;

			drag.Data.Text += "\n" + selected.Asset.RelativePath;
		}

		drag.Execute();
		return true;
	}

	private void OpenTextureContextMenu( object item )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		SelectItem( entry );
		SelectTexture( entry, false );

		var menu = new ContextMenu( this );
		menu.AddOption( "Open in Editor", "edit", () => OnOpenInEditor?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.AddOption( "Open in Asset Browser", "search", () => OnOpenInAssetBrowser?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.OpenAtCursor( false );
	}

	private void PaintTextureItem( VirtualWidget item )
	{
		if ( item.Object is not HammerTextureEntry entry )
			return;

		var rect = item.Rect.Shrink( 1 );
		var active = Paint.HasPressed;
		var selected = Paint.HasSelected || Paint.HasPressed;
		var hover = !selected && Paint.HasMouseOver;

		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( rect );

		if ( selected )
		{
			Paint.SetPen( Theme.Primary, 2 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}
		else if ( hover )
		{
			Paint.SetPen( Theme.ControlBackground.Lighten( 0.35f ), 1 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}

		var previewRect = rect;
		previewRect.Width = PreviewSize;
		previewRect.Height = PreviewSize;
		previewRect.Left += MathF.Max( 0, (rect.Width - PreviewSize) * 0.5f );

		entry.DrawPreview( previewRect, active );

		var nameRect = previewRect;
		nameRect.Top = previewRect.Bottom;
		nameRect.Height = 12;

		Paint.ClearPen();
		Paint.SetBrush( selected ? Theme.Primary.Lighten( active ? -0.1f : 0.05f ) : new Color( 0.0f, 0.05f, 0.85f ) );
		Paint.DrawRect( nameRect );

		Paint.SetDefaultFont( 6 );
		Paint.SetPen( Color.White );
		var label = Paint.GetElidedText( entry.DisplayName, nameRect.Width - 4, ElideMode.Middle );
		Paint.DrawText( nameRect.Shrink( 2, 0 ), label, TextFlag.LeftCenter | TextFlag.SingleLine );
	}
}

internal sealed class HammerTextureEntry
{
	public Asset Asset { get; }
	public string DisplayName { get; }

	public HammerTextureEntry( Asset asset )
	{
		Asset = asset;
		DisplayName = HammerTextureBrowserDock.TextureDisplayName( asset );
	}

	public void OnScrollEnter()
	{
		if ( Asset is null || Asset.HasCachedThumbnail )
			return;

		EditorEvent.Register( this );
		Asset.GetAssetThumb( true );
	}

	public void OnScrollExit()
	{
		if ( Asset is null )
			return;

		Asset.CancelThumbBuild();
		EditorEvent.Unregister( this );
	}

	public void DrawPreview( Rect rect, bool active )
	{
		Paint.ClearPen();
		Paint.SetBrush( active ? Color.Black.Lighten( 0.08f ) : Color.Black );
		Paint.DrawRect( rect );

		var thumb = Asset?.GetAssetThumb( true ) ?? AssetType.Material?.Icon128;
		if ( thumb is null )
			return;

		var drawRect = rect;
		var scale = MathF.Min( rect.Width / thumb.Width, rect.Height / thumb.Height );
		if ( scale > 0 && float.IsFinite( scale ) )
		{
			drawRect.Width = MathF.Min( rect.Width, thumb.Width * scale );
			drawRect.Height = MathF.Min( rect.Height, thumb.Height * scale );
			drawRect.Left = rect.Left + (rect.Width - drawRect.Width) * 0.5f;
			drawRect.Top = rect.Top + (rect.Height - drawRect.Height) * 0.5f;
		}

		Paint.BilinearFiltering = true;
		Paint.Draw( drawRect, thumb );
		Paint.BilinearFiltering = false;
	}
}

internal static class HammerMaterialSelection
{
	private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
	private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

	private static Type HammerType;
	private static bool HasResolvedHammerType;

	public static void SetCurrentMaterial( Asset asset ) => InvokeHammerAssetMethod( "SetCurrentMaterial", asset );
	public static void SelectFacesUsingMaterial( Asset asset ) => InvokeHammerAssetMethod( "SelectFacesUsingMaterial", asset );
	public static void AssignAssetToSelection( Asset asset ) => InvokeHammerAssetMethod( "AssignAssetToSelection", asset );

	public static HashSet<string> GetUsedMaterialKeys()
	{
		var keys = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		try
		{
			var activeMap = GetHammerType()?.GetProperty( "ActiveMap", StaticFlags )?.GetValue( null );
			if ( activeMap is null )
				return keys;

			var world = activeMap.GetType().GetProperty( "World", InstanceFlags )?.GetValue( activeMap );
			if ( world is null )
				return keys;

			CollectUsedMaterialKeys( world, keys );
		}
		catch
		{
			// Hammer throws when no map is open; in that case "Only used textures" should simply show none.
			return keys;
		}

		return keys;
	}

	private static void CollectUsedMaterialKeys( object node, HashSet<string> keys )
	{
		if ( node is null )
			return;

		var materialMethod = node.GetType().GetMethod( "GetFaceMaterialAssets", InstanceFlags );
		if ( materialMethod is not null && materialMethod.Invoke( node, null ) is IEnumerable materials )
		{
			foreach ( var item in materials )
			{
				if ( item is not Asset asset )
					continue;

				keys.Add( asset.RelativePath );
				keys.Add( asset.AbsolutePath );
				keys.Add( HammerTextureBrowserDock.TextureDisplayName( asset ) );
			}
		}

		var children = node.GetType().GetProperty( "Children", InstanceFlags )?.GetValue( node ) as IEnumerable;
		if ( children is null )
			return;

		foreach ( var child in children )
		{
			CollectUsedMaterialKeys( child, keys );
		}
	}

	private static void InvokeHammerAssetMethod( string methodName, Asset asset )
	{
		if ( asset is null )
			return;

		var method = GetHammerType()?.GetMethod( methodName, StaticFlags, binder: null, types: new[] { typeof( Asset ) }, modifiers: null );
		if ( method is null )
			return;

		try
		{
			method.Invoke( null, new object[] { asset } );
		}
		catch
		{
			// This dock still works as a browser if Hammer is not the active editor surface.
		}
	}

	private static Type GetHammerType()
	{
		if ( HasResolvedHammerType )
			return HammerType;

		HasResolvedHammerType = true;

		foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
		{
			HammerType = assembly.GetType( "Editor.MapEditor.Hammer" );
			if ( HammerType is not null )
				break;
		}

		return HammerType;
	}
}
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

internal static class WeaponMaterialPipeline
{
	private const string PreviewMaterialFormatVersion = "preview-material-v2";

	private static readonly HashSet<string> SupportedImageExtensions =
		new( StringComparer.OrdinalIgnoreCase )
		{
			".png", ".tga", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".dds", ".exr"
		};

	private static readonly string[] NearbyFolderNames =
		["textures", "texture", "materials", "material", "maps"];

	private sealed record TextureCandidate(
		string Path,
		string GroupName,
		WeaponTextureChannel Channel,
		int Priority );

	internal sealed record GeneratedTextureCopy(
		string RelativePath,
		string SourceAbsolute );

	public static List<SourceMaterialBinding> DiscoverAndPreparePreview(
		string absoluteSource,
		string cacheRoot,
		Asset modelAsset,
		Model? model,
		List<RigAuditIssue> issues,
		IEnumerable<string>? knownMaterialSlots = null )
	{
		var candidates = DiscoverTextureCandidates( absoluteSource );
		var slots = DiscoverMaterialSlots( modelAsset, model );
		slots.AddRange( knownMaterialSlots?
			.Where( slot => !string.IsNullOrWhiteSpace( slot ) )
			?? [] );
		var embeddedSlots = DiscoverEmbeddedMaterialNames(
			absoluteSource,
			candidates.Select( candidate => candidate.GroupName ) )
			.Select( name => $"{name}.vmat" )
			.ToArray();
		slots.AddRange( embeddedSlots );
		var embeddedSet = embeddedSlots.ToHashSet( StringComparer.OrdinalIgnoreCase );
		slots = slots
			.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )
			.GroupBy( slot => NormalizeName( Path.GetFileNameWithoutExtension( slot ) ) )
			.Select( group => group.FirstOrDefault( embeddedSet.Contains ) ?? group.First() )
			.ToList();
		if ( slots.Count == 0 )
		{
			// Some interchange compilers omit unresolved material metadata. Texture set names
			// are the best deterministic fallback for the original slot labels.
			slots.AddRange( candidates
				.Select( candidate => candidate.GroupName )
				.Distinct( StringComparer.OrdinalIgnoreCase )
				.Select( name => $"{name}.vmat" ) );
		}

		var groups = candidates
			.GroupBy( candidate => NormalizeName( candidate.GroupName ) )
			.Where( group => !string.IsNullOrWhiteSpace( group.Key ) )
			.ToDictionary(
				group => group.Key,
				group => group.ToArray(),
				StringComparer.OrdinalIgnoreCase );
		var bindings = new List<SourceMaterialBinding>();
		var usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var slot in slots
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( name => name, StringComparer.OrdinalIgnoreCase ) )
		{
			var displayName = Path.GetFileNameWithoutExtension( slot );
			var outputName = UniqueOutputName(
				WeaponAnimationDocument.Slugify( displayName ),
				usedNames );
			var binding = new SourceMaterialBinding
			{
				SourceMaterialPath = StoredMaterialSlot( slot ),
				Name = displayName,
				OutputName = outputName
			};

			var matchingGroup = FindBestGroup( displayName, groups );
			if ( matchingGroup is not null )
			{
				foreach ( var channelGroup in matchingGroup
					.GroupBy( candidate => candidate.Channel )
					.OrderBy( group => group.Key ) )
				{
					var candidate = channelGroup
						.OrderByDescending( item => item.Priority )
						.ThenBy( item => item.Path, StringComparer.OrdinalIgnoreCase )
						.First();
					var hash = WeaponSourceImporter.HashFile( candidate.Path );
					var assetPath = EnsureTextureInsideAssets(
						candidate.Path,
						cacheRoot,
						hash );
					binding.Textures.Add( new SourceTextureMap
					{
						Channel = candidate.Channel,
						OriginalPath = candidate.Path,
						AssetPath = assetPath,
						Sha256 = hash
					} );
				}
			}

			if ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null
				&& !binding.HasUsableTextures )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.packed_orm",
					Message =
						$"Material '{displayName}' only has a packed ORM texture. "
						+ "Separate color, normal, roughness, or metalness maps are required "
						+ "for automatic assignment.",
					Severity = ValidationSeverity.Warning
				} );
			}
			else if ( !binding.HasUsableTextures )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.textures_missing",
					Message =
						$"No nearby texture set matched material '{displayName}'. "
						+ "That slot will use the default material.",
					Severity = ValidationSeverity.Warning
				} );
			}
			else if ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null
				&& binding.FindTexture( WeaponTextureChannel.Roughness ) is null
				&& binding.FindTexture( WeaponTextureChannel.Metalness ) is null )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.packed_orm",
					Message =
						$"Material '{displayName}' only has a packed ORM texture. "
						+ "Separate roughness and metalness maps are required for automatic assignment.",
					Severity = ValidationSeverity.Warning
				} );
			}

			bindings.Add( binding );
		}

		PreparePreviewAssets( bindings, cacheRoot );
		return bindings;
	}

	public static IReadOnlyList<HostMaterialRemap> PreviewRemaps(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )
			.Select( binding => new HostMaterialRemap(
				ResourceMaterialSlot( binding.SourceMaterialPath ),
				binding.HasUsableTextures
					&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )
						? binding.PreviewMaterialPath
						: "materials/default.vmat" ) )
			.ToArray();

	public static IReadOnlyList<HostMaterialRemap> OutputRemaps(
		WeaponAnimationDocument document,
		string relativeRoot )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		return document.Source.Materials
			.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )
			.Select( binding => new HostMaterialRemap(
				ResourceMaterialSlot( binding.SourceMaterialPath ),
				binding.HasUsableTextures
					? $"{relativeRoot}/materials/{slug}_{binding.OutputName}.vmat"
					: "materials/default.vmat" ) )
			.ToArray();
	}

	public static bool RequiresPreviewRefresh( WeaponAnimationDocument document ) =>
		document.Source.NeedsModelDocWrapper
		&& (document.Source.Materials.Count == 0
			|| document.Source.CompiledModelPath.StartsWith(
				".weaponanim-cache/",
				StringComparison.OrdinalIgnoreCase )
			|| document.Source.Materials.Any( binding =>
				Path.GetExtension( binding.SourceMaterialPath ).Equals(
					".vmat",
					StringComparison.OrdinalIgnoreCase ) )
			|| document.Source.Materials.Any( binding =>
				binding.HasUsableTextures
				&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )
				&& (binding.PreviewMaterialPath.StartsWith(
						".weaponanim-cache/",
						StringComparison.OrdinalIgnoreCase )
					|| binding.PreviewMaterialPath.Contains(
						"/texture-definitions/",
						StringComparison.OrdinalIgnoreCase )) ));

	public static Dictionary<string, string> BuildOutputTextFiles(
		WeaponAnimationDocument document,
		string relativeRoot )
	{
		var files = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		foreach ( var binding in document.Source.Materials
			.Where( binding => binding.HasUsableTextures ) )
		{
			var texturePaths = new Dictionary<WeaponTextureChannel, string>();
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				var imageName = OutputTextureImageName( slug, binding, texture );
				var imageRelative = $"textures/{imageName}";
				var texturePath = $"{relativeRoot}/{imageRelative}";
				texturePaths[texture.Channel] = texturePath;
			}

			files[$"materials/{slug}_{binding.OutputName}.vmat"] =
				WriteVmat( texturePaths );
		}

		return files;
	}

	public static IReadOnlyList<GeneratedTextureCopy> BuildOutputTextureCopies(
		WeaponAnimationDocument document )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		var copies = new List<GeneratedTextureCopy>();
		foreach ( var binding in document.Source.Materials
			.Where( binding => binding.HasUsableTextures ) )
		{
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				var source = ResolveTextureAbsolute( texture );
				copies.Add( new GeneratedTextureCopy(
					$"textures/{OutputTextureImageName( slug, binding, texture )}",
					source ) );
			}
		}

		return copies;
	}

	internal static IReadOnlyList<SourceMaterialBinding> DiscoverForTests(
		IEnumerable<string> materialSlots,
		IEnumerable<string> texturePaths )
	{
		var candidates = texturePaths
			.Select( TryCreateCandidate )
			.Where( candidate => candidate is not null )
			.Cast<TextureCandidate>()
			.ToArray();
		var groups = candidates
			.GroupBy( candidate => NormalizeName( candidate.GroupName ) )
			.ToDictionary(
				group => group.Key,
				group => group.ToArray(),
				StringComparer.OrdinalIgnoreCase );
		var usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		return materialSlots
			.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )
			.Select( slot =>
		{
			var name = Path.GetFileNameWithoutExtension( slot );
			var binding = new SourceMaterialBinding
			{
				SourceMaterialPath = StoredMaterialSlot( slot ),
				Name = name,
				OutputName = UniqueOutputName(
					WeaponAnimationDocument.Slugify( name ),
					usedNames )
			};
			var group = FindBestGroup( name, groups );
			if ( group is not null )
			{
				binding.Textures = group
					.GroupBy( candidate => candidate.Channel )
					.Select( channel => channel.OrderByDescending( item => item.Priority ).First() )
					.Select( item => new SourceTextureMap
					{
						Channel = item.Channel,
						OriginalPath = item.Path,
						AssetPath = item.Path
					} )
					.ToList();
			}
			return binding;
		} ).ToArray();
	}

	internal static IReadOnlyList<string> MatchEmbeddedMaterialNamesForTests(
		IEnumerable<string> textureGroups,
		IEnumerable<string> embeddedStrings ) =>
		MatchEmbeddedMaterialNames( textureGroups, embeddedStrings );

	internal static string PreviewRevision(
		IEnumerable<SourceMaterialBinding> bindings )
	{
		var fingerprint = PreviewMaterialFormatVersion
			+ "\n"
			+ string.Join(
			"\n",
			bindings
				.OrderBy(
					binding => binding.SourceMaterialPath,
					StringComparer.OrdinalIgnoreCase )
				.Select( binding =>
					$"{NormalizeMaterialPath( binding.SourceMaterialPath )}|{binding.OutputName}|"
					+ string.Join(
						",",
						binding.Textures
							.OrderBy( texture => texture.Channel )
							.ThenBy(
								texture => texture.AssetPath,
								StringComparer.OrdinalIgnoreCase )
							.Select( texture =>
								$"{texture.Channel}:{texture.Sha256}:{texture.AssetPath}" ) ) ) );
		return WeaponSourceImporter.HashText( fingerprint )[..16];
	}

	internal static string PreviewRevisionRoot(
		string cacheRoot,
		IEnumerable<SourceMaterialBinding> bindings ) =>
		Path.Combine(
			LegalPreviewCacheRoot( cacheRoot ),
			PreviewRevision( bindings ) );

	internal static IReadOnlyList<string> PreviewMaterialAbsolutePaths(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.Where( binding => binding.HasUsableTextures
				&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath ) )
			.Select( binding => Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				binding.PreviewMaterialPath.Replace(
					'/',
					Path.DirectorySeparatorChar ) ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();

	internal static IReadOnlyList<string> PreviewTextureAbsolutePaths(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.SelectMany( binding => binding.Textures )
			.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm
				&& !string.IsNullOrWhiteSpace( texture.AssetPath ) )
			.Select( texture => Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				texture.AssetPath.Replace(
					'/',
					Path.DirectorySeparatorChar ) ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();

	internal static string LegalPreviewRelativeRootForTests( string cacheRoot )
	{
		var documentFolder = Path.GetFileName(
			cacheRoot.TrimEnd(
				Path.DirectorySeparatorChar,
				Path.AltDirectorySeparatorChar ) );
		return $"weaponanim_preview_cache/{documentFolder}";
	}

	private static void PreparePreviewAssets(
		IEnumerable<SourceMaterialBinding> bindings,
		string cacheRoot )
	{
		var materialBindings = bindings.ToArray();
		var legalCacheRoot = PreviewRevisionRoot( cacheRoot, materialBindings );
		var materialRoot = Path.Combine( legalCacheRoot, "materials" );
		Directory.CreateDirectory( materialRoot );
		AtomicFile.WriteAllText(
			Path.Combine( legalCacheRoot, ".weaponanim-preview-version" ),
			PreviewMaterialFormatVersion );

		// Register source images before the directory watcher sees VMAT consumers. Otherwise
		// the dependency tracker can permanently mark a copied channel as "stopped existing".
		foreach ( var textureAbsolute in PreviewTextureAbsolutePaths( materialBindings ) )
			AssetSystem.RegisterFile( textureAbsolute );

		foreach ( var binding in materialBindings )
		{
			if ( !binding.HasUsableTextures )
			{
				binding.PreviewMaterialPath = "materials/default.vmat";
				continue;
			}

			var texturePaths = new Dictionary<WeaponTextureChannel, string>();
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				texturePaths[texture.Channel] = texture.AssetPath;
			}

			var vmatAbsolute = Path.Combine( materialRoot, $"{binding.OutputName}.vmat" );
			AtomicFile.WriteAllText( vmatAbsolute, WriteVmat( texturePaths ) );
			binding.PreviewMaterialPath = WeaponSourceImporter.RelativeAssetPath( vmatAbsolute );
		}
	}

	private static List<string> DiscoverMaterialSlots( Asset modelAsset, Model? model )
	{
		var slots = new List<string>();
		try
		{
			slots.AddRange( modelAsset.GetUnrecognizedReferencePaths()
				.Where( IsSourceMaterialPath ) );
		}
		catch ( Exception ex )
		{
			Log.Warning(
				$"[Weapon Animator] could not inspect unresolved source material slots: {ex.Message}" );
		}

		if ( model is not null && !model.IsError )
		{
			try
			{
				slots.AddRange( model.Materials
					.Select( material => material.Name )
					.Where( IsSourceMaterialPath ) );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not inspect compiled model material slots: {ex.Message}" );
			}
		}
		return slots
			.Select( NormalizeMaterialPath )
			.Where( path => !path.Contains(
				".weaponanim-cache/",
				StringComparison.OrdinalIgnoreCase ) )
			.Where( path => !IsIgnoredMaterialPath( path ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	private static bool IsSourceMaterialPath( string? path ) =>
		!string.IsNullOrWhiteSpace( path )
		&& Path.GetExtension( path ).Equals( ".vmat", StringComparison.OrdinalIgnoreCase );

	private static List<TextureCandidate> DiscoverTextureCandidates( string sourcePath )
	{
		var directories = NearbyDirectories( sourcePath );
		var files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var directory in directories )
		{
			try
			{
				foreach ( var file in Directory.EnumerateFiles( directory )
					.Where( file => SupportedImageExtensions.Contains( Path.GetExtension( file ) ) )
					.Take( 512 ) )
				{
					files.Add( Path.GetFullPath( file ) );
				}
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not inspect nearby texture folder '{directory}': {ex.Message}" );
			}
		}

		return files
			.Select( TryCreateCandidate )
			.Where( candidate => candidate is not null )
			.Cast<TextureCandidate>()
			.ToList();
	}

	private static IReadOnlyList<string> DiscoverEmbeddedMaterialNames(
		string sourcePath,
		IEnumerable<string> textureGroups )
	{
		if ( !Path.GetExtension( sourcePath ).Equals(
			".fbx",
			StringComparison.OrdinalIgnoreCase ) )
			return [];

		try
		{
			return MatchEmbeddedMaterialNames(
				textureGroups,
				ReadPrintableStrings( sourcePath ) );
		}
		catch ( Exception ex )
		{
			Log.Warning(
				$"[Weapon Animator] could not inspect embedded FBX material labels: {ex.Message}" );
			return [];
		}
	}

	private static IReadOnlyList<string> MatchEmbeddedMaterialNames(
		IEnumerable<string> textureGroups,
		IEnumerable<string> embeddedStrings )
	{
		var strings = embeddedStrings
			.Where( value => value.Length is >= 2 and <= 128
				&& !value.Contains( '/' )
				&& !value.Contains( '\\' )
				&& !value.Contains( '.' ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();
		var names = new List<string>();
		foreach ( var group in textureGroups
			.Distinct( StringComparer.OrdinalIgnoreCase ) )
		{
			var normalizedGroup = NormalizeName( group );
			var match = strings
				.Where( value => NormalizeName( value ).Equals(
					normalizedGroup,
					StringComparison.OrdinalIgnoreCase ) )
				.OrderBy( value => value.Length )
				.ThenBy( value => value, StringComparer.OrdinalIgnoreCase )
				.FirstOrDefault();
			if ( !string.IsNullOrWhiteSpace( match ) )
				names.Add( match );
		}
		return names.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
	}

	private static IEnumerable<string> ReadPrintableStrings( string path )
	{
		using var stream = File.OpenRead( path );
		var builder = new StringBuilder();
		var buffer = new byte[64 * 1024];
		int count;
		while ( (count = stream.Read( buffer, 0, buffer.Length )) > 0 )
		{
			for ( var index = 0; index < count; index++ )
			{
				var value = buffer[index];
				if ( value is >= 32 and <= 126 )
				{
					if ( builder.Length < 512 )
						builder.Append( (char)value );
					continue;
				}

				if ( builder.Length >= 2 )
					yield return builder.ToString();
				builder.Clear();
			}
		}
		if ( builder.Length >= 2 )
			yield return builder.ToString();
	}

	private static IEnumerable<string> NearbyDirectories( string sourcePath )
	{
		var sourceDirectory = Path.GetDirectoryName( sourcePath );
		if ( string.IsNullOrWhiteSpace( sourceDirectory ) )
			yield break;

		var found = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		if ( found.Add( sourceDirectory ) )
			yield return sourceDirectory;

		foreach ( var root in new[]
			{
				sourceDirectory,
				Directory.GetParent( sourceDirectory )?.FullName
			}.Where( root => !string.IsNullOrWhiteSpace( root ) ) )
		{
			foreach ( var folder in NearbyFolderNames )
			{
				var candidate = Path.Combine( root!, folder );
				if ( Directory.Exists( candidate ) && found.Add( candidate ) )
					yield return candidate;
			}

			IEnumerable<string> children;
			try
			{
				children = Directory.EnumerateDirectories( root! ).ToArray();
			}
			catch
			{
				continue;
			}

			foreach ( var child in children.Where( child =>
				NearbyFolderNames.Any( folder =>
					Path.GetFileName( child ).Contains(
						folder,
						StringComparison.OrdinalIgnoreCase ) ) ) )
			{
				if ( found.Add( child ) )
					yield return child;
			}
		}
	}

	private static TextureCandidate? TryCreateCandidate( string path )
	{
		var stem = Path.GetFileNameWithoutExtension( path );
		var normalized = NormalizeSeparators( stem );
		var patterns = new (string Token, WeaponTextureChannel Channel, int Priority)[]
		{
			("occlusion_roughness_metallic", WeaponTextureChannel.PackedOrm, 100),
			("occlusionroughnessmetallic", WeaponTextureChannel.PackedOrm, 100),
			("normal_opengl", WeaponTextureChannel.Normal, 145),
			("normal_gl", WeaponTextureChannel.Normal, 145),
			("nrm_gl", WeaponTextureChannel.Normal, 140),
			("normal_directx", WeaponTextureChannel.Normal, 80),
			("normal_dx", WeaponTextureChannel.Normal, 80),
			("nrm_dx", WeaponTextureChannel.Normal, 75),
			("base_color", WeaponTextureChannel.BaseColor, 120),
			("basecolor", WeaponTextureChannel.BaseColor, 120),
			("albedo", WeaponTextureChannel.BaseColor, 115),
			("diffuse", WeaponTextureChannel.BaseColor, 110),
			("color", WeaponTextureChannel.BaseColor, 100),
			("ambient_occlusion", WeaponTextureChannel.AmbientOcclusion, 120),
			("ambientocclusion", WeaponTextureChannel.AmbientOcclusion, 120),
			("occlusion", WeaponTextureChannel.AmbientOcclusion, 100),
			("roughness", WeaponTextureChannel.Roughness, 120),
			("rough", WeaponTextureChannel.Roughness, 110),
			("metalness", WeaponTextureChannel.Metalness, 120),
			("metallic", WeaponTextureChannel.Metalness, 120),
			("metal", WeaponTextureChannel.Metalness, 100),
			("normal", WeaponTextureChannel.Normal, 110),
			("nrm", WeaponTextureChannel.Normal, 105),
			("diff", WeaponTextureChannel.BaseColor, 90),
			("ao", WeaponTextureChannel.AmbientOcclusion, 90),
			("orm", WeaponTextureChannel.PackedOrm, 90),
			("rma", WeaponTextureChannel.PackedOrm, 85),
			("mra", WeaponTextureChannel.PackedOrm, 85)
		};

		foreach ( var pattern in patterns )
		{
			var marker = $"_{pattern.Token}";
			var index = normalized.LastIndexOf( marker, StringComparison.Ordinal );
			if ( index < 0 && normalized.Equals( pattern.Token, StringComparison.Ordinal ) )
				index = 0;
			if ( index < 0 )
				continue;

			var group = normalized[..index].Trim( '_' );
			if ( string.IsNullOrWhiteSpace( group ) )
				continue;
			return new TextureCandidate(
				path,
				group,
				pattern.Channel,
				pattern.Priority );
		}

		return null;
	}

	private static TextureCandidate[]? FindBestGroup(
		string materialName,
		IReadOnlyDictionary<string, TextureCandidate[]> groups )
	{
		var normalizedMaterial = NormalizeName( materialName );
		var best = groups
			.Select( pair => new
			{
				pair.Value,
				Score = MatchScore( normalizedMaterial, pair.Key )
			} )
			.OrderByDescending( item => item.Score )
			.FirstOrDefault();
		return best is not null && best.Score > 0 ? best.Value : null;
	}

	private static int MatchScore( string material, string group )
	{
		if ( material.Equals( group, StringComparison.OrdinalIgnoreCase ) )
			return 10000;
		if ( material.Contains( group, StringComparison.OrdinalIgnoreCase )
			|| group.Contains( material, StringComparison.OrdinalIgnoreCase ) )
			return 1000 + Math.Min( material.Length, group.Length );
		return 0;
	}

	private static string EnsureTextureInsideAssets(
		string source,
		string cacheRoot,
		string hash )
	{
		// Preview revisions reference immutable, legal resource names rather than arbitrary
		// user filenames or an image which can change underneath the active model.
		var sourceRoot = Path.Combine(
			LegalPreviewCacheRoot( cacheRoot ),
			"source-textures" );
		Directory.CreateDirectory( sourceRoot );
		var fileName =
			$"{WeaponAnimationDocument.Slugify( Path.GetFileNameWithoutExtension( source ) )}"
			+ $"_{hash[..12]}{Path.GetExtension( source ).ToLowerInvariant()}";
		var destination = Path.Combine( sourceRoot, fileName );
		if ( !File.Exists( destination )
			|| !WeaponSourceImporter.HashFile( destination ).Equals(
				hash,
				StringComparison.OrdinalIgnoreCase ) )
		{
			File.Copy( source, destination, true );
		}
		return WeaponSourceImporter.RelativeAssetPath( destination );
	}

	private static string ResolveTextureAbsolute( SourceTextureMap texture )
	{
		if ( !string.IsNullOrWhiteSpace( texture.AssetPath ) )
		{
			var candidate = Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				texture.AssetPath.Replace( '/', Path.DirectorySeparatorChar ) );
			if ( File.Exists( candidate ) )
				return candidate;
		}

		if ( !string.IsNullOrWhiteSpace( texture.OriginalPath )
			&& File.Exists( texture.OriginalPath ) )
			return Path.GetFullPath( texture.OriginalPath );

		throw new FileNotFoundException(
			$"Texture source for {texture.Channel} is missing.",
			texture.AssetPath );
	}

	private static string OutputTextureImageName(
		string slug,
		SourceMaterialBinding binding,
		SourceTextureMap texture )
	{
		var extension = Path.GetExtension(
			string.IsNullOrWhiteSpace( texture.AssetPath )
				? texture.OriginalPath
				: texture.AssetPath );
		if ( !SupportedImageExtensions.Contains( extension ) )
			extension = ".png";
		return $"{slug}_{binding.OutputName}_{ChannelSuffix( texture.Channel )}"
			+ extension.ToLowerInvariant();
	}

	private static string WriteVmat(
		IReadOnlyDictionary<WeaponTextureChannel, string> textures )
	{
		string TexturePath( WeaponTextureChannel channel, string fallback ) =>
			textures.TryGetValue( channel, out var path )
				? path.Replace( '\\', '/' )
				: fallback;
		var metalness = textures.TryGetValue(
			WeaponTextureChannel.Metalness,
			out var metalnessPath )
				? $$"""

						F_METALNESS_TEXTURE 1
						TextureMetalness "{{metalnessPath.Replace( '\\', '/' )}}"
					"""
				: "";

		return $$"""
			// SboxWeaponAnimator generated material.
			Layer0
			{
				shader "shaders/complex.shader"

				F_SPECULAR 1
				TextureAmbientOcclusion "{{TexturePath( WeaponTextureChannel.AmbientOcclusion, "materials/default/default_ao.tga" )}}"
				TextureColor "{{TexturePath( WeaponTextureChannel.BaseColor, "materials/default/default_color.tga" )}}"
				TextureNormal "{{TexturePath( WeaponTextureChannel.Normal, "materials/default/default_normal.tga" )}}"
				TextureRoughness "{{TexturePath( WeaponTextureChannel.Roughness, "materials/default/default_rough.tga" )}}"{{metalness}}
				g_flModelTintAmount "1.000"
				g_vColorTint "[1.000000 1.000000 1.000000 0.000000]"
				g_flRoughnessScaleFactor "1.000"
				g_bFogEnabled "1"
			}
			""";
	}

	private static string NormalizeMaterialPath( string value )
	{
		var normalized = value.Replace( '\\', '/' ).Trim();
		if ( !Path.HasExtension( normalized ) )
			normalized += ".vmat";
		return normalized;
	}

	internal static string StoredMaterialSlot( string value )
	{
		var normalized = NormalizeMaterialPath( value );
		return normalized.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase )
			? normalized[..^5]
			: normalized;
	}

	private static string ResourceMaterialSlot( string value ) =>
		NormalizeMaterialPath( value );

	private static bool IsIgnoredMaterialPath( string path ) =>
		path.Equals( "materials/default.vmat", StringComparison.OrdinalIgnoreCase )
		|| path.Equals( "materials/error.vmat", StringComparison.OrdinalIgnoreCase )
		|| path.Equals(
			"materials/tools/toolsinvisible.vmat",
			StringComparison.OrdinalIgnoreCase );

	private static string LegalPreviewCacheRoot( string cacheRoot )
	{
		return Path.Combine(
			WeaponSourceImporter.GetContentRoot(),
			LegalPreviewRelativeRootForTests( cacheRoot ).Replace(
				'/',
				Path.DirectorySeparatorChar ) );
	}

	private static string NormalizeSeparators( string value )
	{
		var builder = new StringBuilder( value.Length );
		var previousSeparator = false;
		foreach ( var character in value.ToLowerInvariant() )
		{
			var separator = !char.IsLetterOrDigit( character );
			if ( separator )
			{
				if ( !previousSeparator )
					builder.Append( '_' );
			}
			else
			{
				builder.Append( character );
			}
			previousSeparator = separator;
		}
		return builder.ToString().Trim( '_' );
	}

	private static string NormalizeName( string value ) =>
		new( value
			.Where( char.IsLetterOrDigit )
			.Select( char.ToLowerInvariant )
			.ToArray() );

	private static string UniqueOutputName(
		string baseName,
		HashSet<string> usedNames )
	{
		if ( string.IsNullOrWhiteSpace( baseName ) )
			baseName = "material";
		var candidate = baseName;
		var suffix = 2;
		while ( !usedNames.Add( candidate ) )
			candidate = $"{baseName}_{suffix++}";
		return candidate;
	}

	private static string ChannelSuffix( WeaponTextureChannel channel ) => channel switch
	{
		WeaponTextureChannel.BaseColor => "color",
		WeaponTextureChannel.Normal => "normal",
		WeaponTextureChannel.Roughness => "roughness",
		WeaponTextureChannel.Metalness => "metalness",
		WeaponTextureChannel.AmbientOcclusion => "ao",
		_ => "orm"
	};
}