823 results

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" )]
public sealed class HammerTextureBrowserDock : Widget
{
	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();
	}

	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
{
	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();
	}

	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;
	}
}
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};" );
    }
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
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);
	}
}
using Sandbox;
using Editor;

namespace RedSnail.RoadTool.Editor;

/// <summary>
/// Create and manage road and road intersection.
/// </summary>
[Title("Create Road/Intersection")]
[Icon("roundabout_left")]
[Alias("intersection")]
[Group("1")]
[Order(0)]
public class IntersectionTool : EditorTool
{
	public override void OnEnabled()
	{

	}

	public override Widget CreateToolSidebar()
	{
		ToolSidebarWidget sidebar = new ToolSidebarWidget();
		sidebar.AddTitle("Intersection", "roundabout_left");

		Layout group = sidebar.AddGroup("Create");
		Layout row = Layout.Row();

		IconButton road = sidebar.CreateButton("Create Road", "route", null, CreateRoad, true, row);
		IconButton inter = sidebar.CreateButton("Create Intersection", "roundabout_left", null, CreateIntersection, true, row);

		row.Spacing = 5;
		row.AddStretchCell();

		group.Add(row);

		sidebar.Layout.Add(group);
		sidebar.Layout.AddStretchCell();
		return sidebar;
	}

	private static void CreateRoad()
	{
		GameObject go = SceneEditorSession.Active.Scene.CreateObject();
		go.Name = "Road";
		go.AddComponent<RoadComponent>();
	}

	private static void CreateIntersection()
	{
		GameObject go = SceneEditorSession.Active.Scene.CreateObject();
		go.Name = "Road Intersection";
		go.AddComponent<RoadIntersectionComponent>();
	}
}
#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 );
		}
	}
}
namespace NodeEditorPlus.Blackboard;

public interface IBlackboardNodeGraph : INodeGraph
{
	IEnumerable<IBlackboardParameter> Parameters { get; }

	void AddParameter( IBlackboardParameter parameter );
	void RemoveParameter( IBlackboardParameter parameter );

	IBlackboardParameter FindParameter( Guid identifier );

	string SerializeParameters( IEnumerable<IBlackboardParameter> parameters );
	IEnumerable<IBlackboardParameter> DeserializeParameters( string serialized );
}
namespace ShaderGraphPlus;

public class ClassBlackboardParameterType : IBlackboardParameterType
{
	public virtual string Identifier => Type.FullName;
	public TypeDescription Type { get; }

	public DisplayInfo DisplayInfo { get; protected set; }

	protected virtual string DefaultBaseName => "Parameter";

	public ClassBlackboardParameterType( TypeDescription type )
	{
		Type = type;

		if ( Type is not null )
			DisplayInfo = DisplayInfo.ForType( Type.TargetType );
		else
			DisplayInfo = new DisplayInfo();
	}

	private string CheckName( ShaderGraphPlus graph, string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) )
		{
			var id = 0;
			while ( graph.HasParameterWithName( $"{DefaultBaseName}{id}" ) )
			{
				id++;
			}

			return $"{DefaultBaseName}{id}";
		}
		else
		{
			return name;
		}
	}

	public virtual IBlackboardParameter CreateParameter( INodeGraph graph, string name = "" )
	{
		var sg = graph as ShaderGraphPlus;

		var parameter = Type.Create<BlackboardParameter>();
		parameter.Name = CheckName( sg, name );
		parameter.Graph = sg;

		return parameter;
	}

	internal static ClassBlackboardParameterType HookupParameterType( TypeDescription type )
	{
		var parameterType = new ClassBlackboardParameterType( type );

		// Use these specific ClassBlackboardParameterType's instead. Fallback to the default just in case.
		if ( type.TargetType.IsAssignableTo( typeof( IBlackboardMaterialParameter ) ) ||
			 type.TargetType.IsAssignableTo( typeof( BlackboardTextureMaterialParameter ) ) ||
			 type.TargetType.IsAssignableTo( typeof( SamplerStateParameter ) )
		)
		{
			parameterType = new MaterialParameterType( type );
		}
		else if ( type.TargetType.IsAssignableTo( typeof( IBlackboardSubgraphParameter ) ) )
		{
			parameterType = new SubgraphParameterType( type );
		}
		if ( type.TargetType.IsAssignableTo( typeof( IBlackboardShaderFeatureParameter ) ) )
		{
			parameterType = new ShaderFeatureParameterType( type );
		}

		return parameterType;
	}
}

public sealed class GroupParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => "Group";

	public GroupParameterType( TypeDescription type ) : base( type )
	{
	}
}

public sealed class MaterialParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => "MaterialParameter";

	public MaterialParameterType( TypeDescription type ) : base( type )
	{
	}
}

public sealed class SubgraphParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => Type.TargetType.IsAssignableTo( typeof( IBlackboardSubgraphInputParameter ) ) ? "SubgraphInput" : "SubgraphOutput";

	public SubgraphParameterType( TypeDescription type ) : base( type )
	{
	}
}

public sealed class ShaderFeatureParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => "ShaderFeature";

	public ShaderFeatureParameterType( TypeDescription type ) : base( type )
	{
	}

	public override IBlackboardParameter CreateParameter( INodeGraph graph, string name = "" )
	{
		var sg = graph as ShaderGraphPlus;
		var parameter = base.CreateParameter( graph );

		if ( parameter is ShaderFeatureEnumParameter shaderFeatureEnum )
		{
			shaderFeatureEnum.Options.Add( new ShaderFeatureEnumOption() { Name = "A" } );
			shaderFeatureEnum.Options.Add( new ShaderFeatureEnumOption() { Name = "B" } );
		}

		return parameter;
	}
}
namespace ShaderGraphPlus.Nodes;

[Title( "Boolean Combo Switch" ), Category( "Utility/Logic" ), Icon( "alt_route" )]
[InternalNode]
public sealed class BooleanFeatureSwitchNode : ShaderNodePlus, IParameterNode, IBlackboardNode
{
	[Hide, JsonIgnore, Browsable( false )]
	public override Color NodeTitleColor { get; set; } = ShaderGraphPlusTheme.NodeHeaderColors.LogicNode;

	[Hide, JsonIgnore, Browsable( false )]
	public override string Title => $"F_{Feature.Name.ToUpper().Replace( " ", "_" )}";

	[Hide, JsonIgnore, Browsable( false )]
	public string Name => $"F_{Feature.Name.ToUpper().Replace( " ", "_" )}";

	[Hide, Browsable( false )]
	public Guid ParameterIdentifier { get; set; }

	[Hide, JsonIgnore, Browsable( false )]
	public ShaderFeatureBoolean Feature => GetFeature();

	[Input, Hide]
	[Title( "True" )]
	public NodeInput InputTrue { get; set; }

	[Input, Hide]
	[Title( "False" )]
	public NodeInput InputFalse { get; set; }

	private ShaderFeatureBooleanParameter GetFeatureParameter()
	{
		if ( Graph is ShaderGraphPlus graph )
		{
			var parameter = graph.FindParameter<ShaderFeatureBooleanParameter>( ParameterIdentifier );

			if ( parameter != null )
			{
				return parameter;
			}
		}

		return new ShaderFeatureBooleanParameter();
	}

	private ShaderFeatureBoolean GetFeature()
	{
		var parameter = GetFeatureParameter();

		if ( parameter.IsValid )
		{
			var featureBoolean = new ShaderFeatureBoolean
			{
				Name = parameter.Name,
				Description = parameter.Description,
				HeaderName = parameter.HeaderName,
			};

			return featureBoolean;
		}

		return new ShaderFeatureBoolean();
	}

	[Output, Hide]
	public NodeResult.Func Result => ( GraphCompiler compiler ) =>
	{
		var inputs = new List<NodeInput>
		{
			InputTrue,
			InputFalse
		};

		var preview = GetFeatureParameter().Preview;
		var result = compiler.ResultFeatureSwitch( inputs, Feature, preview ? 1 : 0 );

		return result.IsValid ? result : new NodeResult( ResultType.Float, $"1.0f" );
	};
}
using Editor;
using ShaderGraphPlus.Nodes;

namespace ShaderGraphPlus;

[System.AttributeUsage( AttributeTargets.Class )]
internal class SubgraphOnlyAttribute : Attribute
{
	public SubgraphOnlyAttribute()
	{
	}
}

public abstract class BaseNodePlus : IGraphNode
{
	public event Action Changed;

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

	[JsonIgnore, Hide, Browsable( false )]
	public virtual string Subtitle { get; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual DisplayInfo DisplayInfo { get; }

	[JsonIgnore, Hide, Browsable( false )]
	public bool CanClone => true;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool CanRemove => true;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool CanPreview => true;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool CanAddToGraph => true;

	[Hide, Browsable( false )]
	public Vector2 Position { get; set; }

	[JsonIgnore, Hide]
	public INodeGraph _graph;

	[JsonIgnore, Hide, Browsable( false )]
	internal int PreviewID { get; set; }

	[JsonIgnore, Hide, Browsable( false )]
	public bool Processed { get; set; } = false;

	[JsonIgnore, Hide, Browsable( false )]
	public INodeGraph Graph
	{
		get => _graph;
		set
		{
			_graph = value;
			FilterInputsAndOutputs();
		}
	}

	[JsonIgnore, Hide, Browsable( false )]
	public Vector2 ExpandSize { get; set; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool AutoSize => false;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual IEnumerable<IPlugIn> Inputs { get; protected set; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual IEnumerable<IPlugOut> Outputs { get; protected set; }

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

	[JsonIgnore, Hide, Browsable( false )]
	public bool IsReachable => true;

	[Hide, Browsable( false )]
	public Dictionary<string, float> HandleOffsets { get; set; } = new();

	public BaseNodePlus()
	{
		DisplayInfo = DisplayInfo.For( this );
		NewIdentifier();

		(Inputs, Outputs) = GetPlugs( this );
	}

	public override string ToString()
	{
		return $"{DisplayInfo.Fullname}.{Identifier}";
	}

	public void Update()
	{
		Changed?.Invoke();
	}

	public virtual void OnFrame()
	{

	}
	public void ClearError()
	{
		HasError = false;
		ErrorMessage = null;
	}

	public string NewIdentifier()
	{
		Identifier = Guid.NewGuid().ToString();
		return Identifier;
	}

	public virtual NodeUI CreateUI( GraphView view )
	{
		return new NodeUI( view, this, false );
	}

	public Color GetNodeBodyTintColor( GraphView view )
	{
		return NodeBodyTintColor;
	}

	public Color GetNodeTitleColor( GraphView view )
	{
		return NodeTitleColor;
	}

	public virtual Menu CreateContextMenu( NodeUI node )
	{
		return null;
	}

	public virtual void DebugInfo( Menu menu )
	{
		var debugInfoHeading = menu.AddHeading( "Node Debug Info" );

		menu.AddWidget( new Label( $"Node ID : {this.Identifier}" ) );
		if ( this is IParameterNode blackboardSyncable )
		{
			menu.AddWidget( new Label( $"Blackboard ID : {blackboardSyncable.ParameterIdentifier}" ) ).AdjustSize();
		}
		menu.AddWidget( new Label( $"Preview ID : {this.PreviewID}" ) );
		menu.AddWidget( new Label( $"IsReachable? : {this.IsReachable}" ) );
		menu.AddWidget( new Label( $"CanPreview? : {this.CanPreview}" ) );
	}

	[JsonIgnore, Hide, Browsable( false )]
	public virtual Pixmap Thumbnail { get; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual Color NodeBodyTintColor { get; set; } = Color.Parse( "#303030" )!.Value.Lighten( 2.0f );

	[JsonIgnore, Hide, Browsable( false )]
	public virtual Color NodeTitleColor { get; set; } = Color.Gray;

	public virtual void OnPaint( Rect rect )
	{

	}

	public virtual void OnDoubleClick( MouseEvent e )
	{

	}

	[JsonIgnore, Hide, Browsable( false )]
	public bool HasTitleBar => true;

	[JsonIgnore, Hide, Browsable( false )]
	public bool HasSubtitle => !string.IsNullOrWhiteSpace( Subtitle );

	private bool _hasError;
	[JsonIgnore, Hide, Browsable( false )]
	public bool HasError
	{
		get => _hasError;
		set
		{
			_hasError = value;
			Update();
		}
	}

	[JsonIgnore, Hide, Browsable( false )]
	public bool HasWarning { get; set; } = false;

	[System.AttributeUsage( AttributeTargets.Property )]
	public class InputAttribute : Attribute
	{
		/// <summary>
		/// Type of the port.
		/// </summary>
		public System.Type Type;

		/// <summary>
		/// Order of the port.
		/// </summary>
		public int Order;

		public InputAttribute( Type type = null, int order = 0 )
		{
			Type = type;
			Order = order;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class InputDefaultAttribute : Attribute
	{
		public string Input;

		public InputDefaultAttribute( string input )
		{
			Input = input;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class OutputAttribute : Attribute
	{
		/// <summary>
		/// Type of the port.
		/// </summary>
		public System.Type Type;

		/// <summary>
		/// Order of the port.
		/// </summary>
		public int Order;

		public OutputAttribute( Type type = null, int order = 0 )
		{
			Type = type;
			Order = order;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class HideOutputAttribute : Attribute
	{
		public System.Type Type;

		public HideOutputAttribute( Type type = null )
		{
			Type = type;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class NodeValueEditorAttribute : Attribute
	{
		public string ValueName;

		public NodeValueEditorAttribute( string valueName )
		{
			ValueName = valueName;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class RangeAttribute : Attribute
	{
		public string Min;
		public string Max;
		public string Step;

		public RangeAttribute( string min, string max, string step )
		{
			Min = min;
			Max = max;
			Step = step;
		}
	}

	/// <summary>
	/// Interface for nodes that want to setup anyting post node object deserializeation.
	/// </summary>
	public interface IInitializeNode
	{
		/// <summary>
		/// Called after the node has been deserialized.
		/// </summary>
		public void InitializeNode();
	}

	/// <summary>
	/// Connects a <see cref="NodeResult.Func"/> property from another node to the <see cref="NodeInput"/> property on this <see cref="BaseNodePlus"/> instance.
	/// </summary>
	/// <param name="targetInputName">The internal input name on this <see cref="BaseNodePlus"/> instance that will be connected to.</param>
	/// <param name="sourceNodeOutputName">The internal output name of the source <see cref="BaseNodePlus"/> that the new connection is coming from.</param>
	/// <param name="sourceNodeIdentifier">The Identifier of the souce <see cref="BaseNodePlus"/> that we are connecting from.</param>
	public void ConnectNode( string targetInputName, string sourceNodeOutputName, string sourceNodeIdentifier )
	{
		if ( Graph == null )
		{
			throw new Exception( "Graph is null!!!" );
		}

		var graph = Graph as ShaderGraphPlus;
		var targetOutputNode = graph.Nodes.Where( x => x.Identifier == sourceNodeIdentifier ).FirstOrDefault();

		if ( targetOutputNode != null )
		{
			var plugIn = Inputs.Where( x => x.Identifier == targetInputName ).FirstOrDefault();
			var targetOutputPlug = targetOutputNode.Outputs.FirstOrDefault( x => x.Identifier == sourceNodeOutputName );

			if ( plugIn == null )
			{
				throw new Exception( $"Cannot find input with name '{targetInputName}' on node '{this}'" );
			}

			if ( targetOutputPlug == null )
			{
				throw new Exception( $"Cannot find output with name '{sourceNodeOutputName}' on node '{targetOutputNode}'" );
			}


			plugIn.ConnectedOutput = targetOutputPlug;
		}
		else
		{
			throw new Exception( $"Cannot find node with Identifier '{sourceNodeIdentifier}'" );
		}
	}

	public static (IEnumerable<IPlugIn> Inputs, IEnumerable<IPlugOut> Outputs) GetPlugs( BaseNodePlus node )
	{
		var type = node.GetType();
		var inputs = new List<BasePlugIn>();
		var outputs = new List<BasePlugOut>();

		var inputProperties = type.GetProperties().OrderBy( x =>
			(x.GetCustomAttribute<InputAttribute>() is InputAttribute input) ? input.Order : 0 );

		foreach ( var propertyInfo in inputProperties )
		{
			if ( propertyInfo.GetCustomAttribute<InputAttribute>() is { } inputAttrib )
			{
				inputs.Add( new BasePlugIn( node, new( propertyInfo ), inputAttrib.Type ?? typeof( object ) ) );
			}
		}

		var outputProperties = type.GetProperties().OrderBy( x =>
			(x.GetCustomAttribute<OutputAttribute>() is OutputAttribute output) ? output.Order : 0 );

		foreach ( var propertyInfo in outputProperties )
		{
			if ( propertyInfo.GetCustomAttribute<OutputAttribute>() is { } outputAttrib )
			{
				outputs.Add( new BasePlugOut( node, new( propertyInfo ), outputAttrib.Type ?? typeof( object ) ) );
			}
		}

		return (inputs, outputs);
	}

	private void FilterInputsAndOutputs()
	{
		if ( _graph is not null )
		{
			if ( Graph is ShaderGraphPlus sgp && !sgp.IsSubgraph && this is not BooleanFeatureSwitchNode && this is IParameterNode )
			{
				Inputs = new List<IPlugIn>();
			}
		}
	}

}

public record BasePlug( BaseNodePlus Node, PlugInfo Info, Type Type ) : IPlug
{
	IGraphNode IPlug.Node => Node;

	Type IPlug.Type { get; set; } = Type;

	public string Identifier => Info.Name;
	public DisplayInfo DisplayInfo => Info.DisplayInfo;

	public ValueEditor CreateEditor( NodeUI node, NodePlug plug )
	{
		var editor = Info.CreateEditor( node, plug, Type );
		if ( editor is not null ) return editor;

		// Default
		{
			var defaultEditor = new DefaultEditor( plug );
		}

		return null;
	}

	public Menu CreateContextMenu( NodeUI node, NodePlug plug )
	{
		return null;
	}

	public void OnDoubleClick( NodeUI node, NodePlug plug, MouseEvent e )
	{

	}

	public bool ShowLabel => true;
	public bool AllowStretch => true;
	public bool ShowConnection => IsReachable;
	public bool InTitleBar => false;

	public bool IsReachable
	{
		get
		{
			var conditional = Info.Property?.GetCustomAttribute<ConditionalVisibilityAttribute>();
			if ( conditional is not null )
			{
				if ( conditional.TestCondition( Node.GetSerialized() ) ) return false;
			}

			return true;
		}
	}

	public string ErrorMessage => null;

	public override string ToString()
	{
		return $"{Node.Identifier}.{Identifier}";
	}

}

public record BasePlugIn( BaseNodePlus Node, PlugInfo Info, Type Type ) : BasePlug( Node, Info, Type ), IPlugIn
{
	IPlugOut IPlugIn.ConnectedOutput
	{
		get
		{
			if ( Info.Property is null )
			{
				return Info.ConnectedPlug;
			}

			if ( Info.Type != typeof( NodeInput ) )
			{
				return null;
			}

			var value = Info.GetInput( Node );

			if ( !value.IsValid )
			{
				return null;
			}


			var node = ((ShaderGraphPlus)Node.Graph).FindNode( value.Identifier );
			var output = node?.Outputs
				.FirstOrDefault( x => x.Identifier == value.Output );

			return output;
		}
		set
		{
			var property = Info.Property;
			if ( property is null )
			{
				Info.ConnectedPlug = value;
				return;
			}

			if ( property.PropertyType != typeof( NodeInput ) )
			{
				return;
			}

			if ( value is null )
			{
				property.SetValue( Node, default( NodeInput ) );
				return;
			}

			if ( value is not BasePlug fromPlug )
			{
				return;
			}

			property.SetValue( Node, new NodeInput
			{
				Identifier = fromPlug.Node.Identifier,
				Output = fromPlug.Identifier
			} );
		}
	}

	public float? GetHandleOffset( string name )
	{
		if ( Node.HandleOffsets.TryGetValue( name, out var value ) )
		{
			return value;
		}
		return null;
	}

	public void SetHandleOffset( string name, float? value )
	{
		if ( value is null ) Node.HandleOffsets.Remove( name );
		else Node.HandleOffsets[name] = value.Value;
	}
}

public record BasePlugOut( BaseNodePlus Node, PlugInfo Info, Type Type ) : BasePlug( Node, Info, Type ), IPlugOut;

public class PlugInfo
{
	public Guid Id { get; set; }
	public string Name { get; set; }
	public Type Type { get; set; }
	public DisplayInfo DisplayInfo { get; set; }
	public PropertyInfo Property { get; set; } = null;
	public IPlugOut ConnectedPlug { get; set; } = null;

	public PlugInfo()
	{
		DisplayInfo = new();
	}
	public PlugInfo( PropertyInfo property )
	{
		Name = property.Name;
		Type = property.PropertyType;
		var info = DisplayInfo.ForMember( Type );
		info.Name = property.Name;
		var titleAttr = property.GetCustomAttribute<TitleAttribute>();
		if ( titleAttr is not null )
		{
			info.Name = titleAttr.Value;
		}
		var descriptionAttr = property.GetCustomAttribute<DescriptionAttribute>();
		if ( descriptionAttr is not null )
		{
			info.Description = descriptionAttr.Value;
		}

		DisplayInfo = info;
		Property = property;
	}

	public NodeInput GetInput( BaseNodePlus node )
	{
		if ( Property is not null )
		{
			return (NodeInput)Property.GetValue( node )!;
		}

		return default;
	}

	public ValueEditor CreateEditor( NodeUI node, NodePlug plug, Type type )
	{
		var editor = Property?.GetCustomAttribute<BaseNodePlus.NodeValueEditorAttribute>();

		if ( editor is not null )
		{
			if ( type == typeof( int ) )
			{
				var slider = new IntValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				var range = Property.GetCustomAttribute<BaseNodePlus.RangeAttribute>();
				if ( range != null )
				{
					slider.Bind( "Min" ).From( node.Node, range.Min );
					slider.Bind( "Max" ).From( node.Node, range.Max );
				}
				else if ( Property.GetCustomAttribute<MinMaxAttribute>() is MinMaxAttribute minMax )
				{
					slider.Min = (int)minMax.MinValue;
					slider.Max = (int)minMax.MaxValue;
				}

				return slider;
			}

			if ( type == typeof( float ) )
			{
				var slider = new FloatValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				var range = Property.GetCustomAttribute<BaseNodePlus.RangeAttribute>();
				if ( range != null )
				{
					slider.Bind( "Min" ).From( node.Node, range.Min );
					slider.Bind( "Max" ).From( node.Node, range.Max );
					slider.Bind( "Step" ).From( node.Node, range.Step );
				}
				else if ( Property.GetCustomAttribute<MinMaxAttribute>() is MinMaxAttribute minMax )
				{
					slider.Min = minMax.MinValue;
					slider.Max = minMax.MaxValue;
				}

				return slider;
			}

			if ( type == typeof( Color ) )
			{
				var slider = new ColorValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				return slider;
			}

			if ( type == typeof( Gradient ) )
			{
				var slider = new GradientValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				return slider;
			}
		}
		return null;
	}
}

namespace ShaderGraphPlus.Nodes;

/// <summary>
///
/// </summary>
[Title( "Pixel Plot" ), Category( "Effects" ), Icon( "grid_on" )]
public sealed class PixelPlotNode : ShaderNodePlus
{
	[JsonIgnore, Hide, Browsable( false )]
	public override Color NodeTitleColor => ShaderGraphPlusTheme.NodeHeaderColors.FunctionNode;

	[Hide]
	public string PixelPlot => @"	
float4 PixelPlot( in Texture2D vColorTex, in SamplerState sSampler, float2 vUv , float2 vGridSize , float flBoarderThickness)
{
	float2 vGridBlock = 1.0f / vGridSize;
	float2 vUvGrid = floor( vUv * vGridSize ) / vGridSize; // Divide By Gridsize so that uvspace is clamped to  0 to 1.
	float2 vGridBoarder = step( 0.5f - flBoarderThickness, frac( vUv / vGridBlock ) ) *
						 step( frac( vUv / vGridBlock ), 0.5f + flBoarderThickness );

	return vColorTex.Sample( sSampler, vUvGrid ) * ( vGridBoarder.x * vGridBoarder.y );
}
";

	/// <summary>
	/// Texture object to apply the effect to.
	/// </summary>
	[Title( "Texture2D" )]
	[Input( typeof( Texture ) )]
	[Hide]
	public NodeInput Texture2D { get; set; }

	/// <summary>
	/// Coordinates to sample this texture
	/// </summary>
	[Title( "Coordinates" )]
	[Input( typeof( Vector2 ) )]
	[Hide]
	public NodeInput Coords { get; set; }

	/// <summary>
	/// How the effect is filtered and wrapped when sampled
	/// </summary>
	[Title( "Sampler" )]
	[Input( typeof( Sampler ) )]
	[Hide]
	public NodeInput Sampler { get; set; }

	[Input( typeof( Vector2 ) )]
	[Hide]
	public NodeInput GridSize { get; set; }

	[Input( typeof( float ) )]
	[Hide]
	public NodeInput BoarderThickness { get; set; }

	[InlineEditor( Label = false ), Group( "Sampler" )]
	[ShowIf( nameof( ShowDefaultSamplerState ), true )]
	public Sampler SamplerState { get; set; } = new Sampler();

	[InputDefault( nameof( GridSize ) )]
	public Vector2 DefaultGridSize { get; set; } = new Vector2( 24.0f, 24.0f );

	[InputDefault( nameof( BoarderThickness ) )]
	public float DefaultBoarderThickness { get; set; } = 0.420f;

	[JsonIgnore, Hide, Browsable( false )]
	private bool ShowDefaultSamplerState { get; set; } = false;

	[Output( typeof( Vector4 ) ), Title( "Result" )]
	[Hide]
	public NodeResult.Func Result => ( GraphCompiler compiler ) =>
	{
		var coordsResult = compiler.Result( Coords );
		var textureResult = compiler.Result( Texture2D );
		var samplerResult = compiler.ResultSamplerOrDefault( Sampler, SamplerState );
		var gridResult = compiler.ResultOrDefault( GridSize, DefaultGridSize );
		var boarderThicknessResult = compiler.ResultOrDefault( BoarderThickness, DefaultBoarderThickness );

		ShowDefaultSamplerState = !Sampler.IsValid;

		if ( !textureResult.IsValid )
		{
			return NodeResult.MissingInput( "Texture2D" );
		}
		else if ( textureResult.ResultType is not ResultType.Texture2D )
		{
			return NodeResult.Error( $"Input to TexObject is not a texture object!" );
		}

		string func = compiler.RegisterHLSLFunction( PixelPlot, "PixelPlot" );
		string funcCall = compiler.ResultHLSLFunction( func, $"{textureResult}, {samplerResult}, {(coordsResult.IsValid ? $"{coordsResult.Cast( 2 )}" : "i.vTextureCoords.xy")}, {gridResult}, {boarderThicknessResult}" );

		return new NodeResult( ResultType.Vector4, funcCall );
	};
}

using Facepunch.ActionGraphs;

namespace ShaderGraphPlus.Nodes;

/// <summary>
/// Get the dimensions of a Texture2D.
/// </summary>
[Title( "Get Texture2D Dimensions" ), Category( "Textures" ), Icon( "straighten" )]
public sealed class GetTexture2DDimensions : ShaderNodePlus
{
	[JsonIgnore, Hide, Browsable( false )]
	public override Color NodeTitleColor => ShaderGraphPlusTheme.NodeHeaderColors.FunctionNode;

	[Title( "Texture2D" )]
	[Input( typeof( Texture ) )]
	[Hide]
	public NodeInput TextureInput { get; set; }

	[Title( "Mip Level" )]
	[Input( typeof( int ) )]
	[Hide]
	public NodeInput MipLevelInput { get; set; }

	[JsonIgnore, Hide]
	public override bool CanPreview => false;

	[InputDefault( nameof( MipLevelInput ) )]
	public int DefaultMipLevel { get; set; } = 0;

	public GetTexture2DDimensions()
	{
		ExpandSize = new Vector2( 8, 0 );
	}

	[Output( typeof( Vector2 ) )]
	[Title( "Size" )]
	[Hide]
	public NodeResult.Func Result => ( GraphCompiler compiler ) =>
	{
		ClearError();

		var textureResult = compiler.Result( TextureInput );
		if ( !textureResult.IsValid )
		{
			return NodeResult.MissingInput( "Texture2D" );
		}
		else if ( textureResult.ResultType != ResultType.Texture2D )
		{
			return NodeResult.IncorrectInputType( "Texture2D", ResultType.Texture2D );
		}

		var miplevelResult = compiler.ResultOrDefault( MipLevelInput, DefaultMipLevel );

		return new NodeResult( ResultType.Vector2, $"TextureDimensions2D({textureResult}, {miplevelResult})", constant: false );
	};
}
namespace ShaderGraphPlus;

[System.AttributeUsage( AttributeTargets.Property )]
internal sealed class ShaderFeatureReferenceAttribute : Attribute
{
}

public abstract class ShaderFeatureBase : IValid
{
	/// <summary>
	/// Name of this feature.
	/// </summary>
	public string Name { get; set; }

	/// <summary>
	/// What this feature does.
	/// </summary>
	public string Description { get; set; }

	/// <summary>
	/// Header Name of this Feature that shows up in the Material Editor.
	/// </summary>
	public string HeaderName { get; set; }

	[Hide, JsonIgnore, Browsable( false )]
	public virtual bool IsValid => throw new NotImplementedException();

	public ShaderFeatureBase()
	{
		Name = "";
		Description = "";
		HeaderName = "";
	}

	public string GetFeatureString()
	{
		return $"F_{Name.ToUpper().Replace( " ", "_" )}";
	}

	public string GetDynamicComboString()
	{
		return $"D_{Name.ToUpper().Replace( " ", "_" )}";
	}

	public string GetStaticComboString()
	{
		return $"S_{Name.ToUpper().Replace( " ", "_" )}";
	}

	public virtual string GetOptionRangeString()
	{
		return $"";
	}
}

public sealed class ShaderFeatureBoolean : ShaderFeatureBase
{
	[Hide, JsonIgnore, Browsable( false )]
	public override bool IsValid => !string.IsNullOrWhiteSpace( Name );

	public ShaderFeatureBoolean() : base()
	{

	}

	public override int GetHashCode()
	{
		return System.HashCode.Combine( Name, Description, HeaderName );
	}

	public override string GetOptionRangeString()
	{
		return "0..1";
	}
}

public sealed class ShaderFeatureEnumOption : IValid
{
	[Hide]
	public Guid Id { get; set; }

	[KeyProperty]
	public string Name { get; set; }

	[Hide, JsonIgnore]
	public bool IsValid => !string.IsNullOrWhiteSpace( Name );

	public ShaderFeatureEnumOption()
	{
		Id = Guid.NewGuid();
	}

	public override string ToString()
	{
		return Name;
	}

	public override int GetHashCode()
	{
		return System.HashCode.Combine( Id, Name );
	}
}

public sealed class ShaderFeatureEnum : ShaderFeatureBase
{
	[Hide, JsonIgnore, Browsable( false )]
	public override bool IsValid => !string.IsNullOrWhiteSpace( Name ) && Options.All( x => !string.IsNullOrWhiteSpace( x.Name ) );

	/// <summary>
	/// Options of your feature. Must have no special characters. Note : all lowercase letters will be converted to uppercase.
	/// </summary>
	public List<ShaderFeatureEnumOption> Options { get; set; }

	public ShaderFeatureEnum() : base()
	{
		Options = new List<ShaderFeatureEnumOption>();
	}

	public override int GetHashCode()
	{
		var hashcode = System.HashCode.Combine( Name, Description, HeaderName );

		foreach ( var option in Options )
		{
			hashcode += option.GetHashCode();
		}

		return hashcode;
	}

	public override string GetOptionRangeString()
	{
		return $"0..{Options.Count - 1}";
	}
}


using Editor;
using static ShaderGraphPlus.GraphCompiler;

namespace ShaderGraphPlus;

public class Output : Widget
{
	private IssueListView _issueListView;

	private List<GraphCompiler.GraphIssue> _graphIssues;
	public List<GraphCompiler.GraphIssue> GraphIssues
	{
		get => _graphIssues;
		set
		{
			_graphIssues = value;
			//_issueListView.AddItems( value.Cast<object>() );
			_issueListView.SetItems( value.Cast<object>() );
		}
	}

	public Action<BaseNodePlus> OnNodeSelected { get; set; }

	public void ClearErrors()
	{
		foreach ( var error in _issueListView.Items.Where( x => x is GraphIssue issue && issue.IsWarning == false ) )
		{
			_issueListView.RemoveItem( error );
		}
	}

	internal void ClearWarnings()
	{
		foreach ( var warning in _issueListView.Items.Where( x => x is GraphIssue issue && issue.IsWarning == true ) )
		{
			_issueListView.RemoveItem( warning );
		}
	}

	public Output( Widget parent ) : base( parent )
	{
		Name = "Output";
		WindowTitle = "Output";
		SetWindowIcon( "notes" );

		Layout = Layout.Column();

		_issueListView = new( this );
		Layout.Add( _issueListView );
	}
}

public class IssueListView : ListView
{
	private Output _output;

	public IssueListView( Output parent ) : base( parent )
	{
		_output = parent;
		ItemActivated = ( a ) =>
		{
			if ( a is not GraphCompiler.GraphIssue issue )
				return;

			if ( issue.Node != null && issue.Node is not DummyNode )
			{
				_output.OnNodeSelected?.Invoke( issue.Node );
			}
		};

		ItemContextMenu = OpenItemContextMenu;
		ItemSize = new Vector2( 0, 48 );
		ItemSpacing = 0;
		Margin = 0;
	}

	private void OpenItemContextMenu( object item )
	{
		if ( item is GraphCompiler.GraphIssue issue )
		{
			OnOpenItemContextMenuError( item, issue );
		}
	}

	private void OnOpenItemContextMenuError( object item, GraphIssue error )
	{
		var m = new Menu();

		if ( error.Node != null && error.Node is not DummyNode )
		{
			var nodeName = DisplayInfo.ForType( error.Node.GetType() ).Name;

			m.AddOption( "Go to Error", "arrow_upward", () => _output.OnNodeSelected?.Invoke( error.Node ) );
			m.AddOption( "Copy Error", "content_copy", () => EditorUtility.Clipboard.Copy( $"{error.Message}\n{nodeName} #{error.Node.Identifier}" ) );
		}
		else
		{
			m.AddOption( "Copy Error", "content_copy", () => EditorUtility.Clipboard.Copy( $"{error.Message}" ) );
		}

		m.OpenAt( Editor.Application.CursorPosition );
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.WindowBackground );
		Paint.DrawRect( LocalRect );

		base.OnPaint();
	}

	public void OnPaintError( VirtualWidget item, GraphCompiler.GraphIssue error )
	{

		var color = Theme.Red;

		Paint.SetBrush( color.WithAlpha( Paint.HasMouseOver ? 0.1f : 0.03f ) );
		Paint.ClearPen();
		Paint.DrawRect( item.Rect.Shrink( 0, 1 ) );

		Paint.Antialiasing = true;
		Paint.SetPen( color.WithAlpha( Paint.HasMouseOver ? 1 : 0.7f ), 3.0f );
		Paint.ClearBrush();

		var iconRect = item.Rect.Shrink( 12, 0 );
		iconRect.Width = 24;

		Paint.DrawIcon( iconRect, "error", 24 );

		var rect = item.Rect.Shrink( 48, 8, 0, 8 );

		Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 1 : 0.8f ), 3.0f );
		Paint.DrawText( rect, error.Message, (error.Node != null ? TextFlag.LeftTop : TextFlag.LeftCenter) | TextFlag.SingleLine );

		if ( error.Node != null && error.Node is not DummyNode )
		{
			var nodeName = DisplayInfo.ForType( error.Node.GetType() ).Name;
			Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 0.5f : 0.4f ), 3.0f );
			Paint.DrawText( rect, $"{nodeName}", TextFlag.LeftBottom | TextFlag.SingleLine );
		}
	}

	public void OnPaintWarning( VirtualWidget item, GraphCompiler.GraphIssue warning )
	{
		var color = Theme.Yellow;

		Paint.SetBrush( color.WithAlpha( Paint.HasMouseOver ? 0.1f : 0.03f ) );
		Paint.ClearPen();
		Paint.DrawRect( item.Rect.Shrink( 0, 1 ) );

		Paint.Antialiasing = true;
		Paint.SetPen( color.WithAlpha( Paint.HasMouseOver ? 1 : 0.7f ), 3.0f );
		Paint.ClearBrush();

		var iconRect = item.Rect.Shrink( 12, 0 );
		iconRect.Width = 24;

		Paint.DrawIcon( iconRect, "error", 24 );

		var rect = item.Rect.Shrink( 48, 8, 0, 8 );

		Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 1 : 0.8f ), 3.0f );
		Paint.DrawText( rect, warning.Message, (warning.Node != null ? TextFlag.LeftTop : TextFlag.LeftCenter) | TextFlag.SingleLine );

		if ( warning.Node != null )
		{
			var nodeName = DisplayInfo.ForType( warning.Node.GetType() ).Name;
			Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 0.5f : 0.4f ), 3.0f );
			Paint.DrawText( rect, $"{nodeName}", TextFlag.LeftBottom | TextFlag.SingleLine );
		}
	}

	protected override void PaintItem( VirtualWidget item )
	{
		if ( item.Object is GraphCompiler.GraphIssue issue )
		{
			if ( issue.IsWarning )
			{
				OnPaintWarning( item, issue );
			}
			else
			{
				OnPaintError( item, issue );
			}
		}

	}
}
using Editor;

namespace ShaderGraphPlus;

// TODO : Add Line Numbers to the left of the text area. Also add an option to toggle LineNumbers on or off.

public class TextView : Widget
{
	public Action TextChanged;

	private string _contents = "";
	private TextEdit _textEdit;

	public TextView( Widget parent, string windowTitle, string text ) : base( parent )
	{
		Name = windowTitle;
		WindowTitle = windowTitle;
		_contents = text;
		Parent = parent;
		SetWindowIcon( "edit" );

		Layout = Layout.Row();

		// TODO
		var LineNumbers = Layout.AddRow( 1 );
		LineNumbers.AddSpacingCell( 16f );

		//Layout.Add( LineNumbers );

		_textEdit = new TextEdit( this );
		_textEdit.ReadOnly = true;
		_contents = text;
		_textEdit.PlainText = _contents;
		_textEdit.VerticalScrollbarMode = ScrollbarMode.On;
		_textEdit.TextChanged += x =>
		{
			TextChanged?.Invoke();
		};

		_textEdit.SetStyles( $"font-size: 12px; font-weight: regular; color: {Theme.TextControl.Hex};" );

		Layout.Add( _textEdit );
	}


	public string GetTextContents()
	{
		return _contents;
	}

	public void SetTextContents( string text )
	{
		if ( !string.IsNullOrWhiteSpace( text ) )
		{
			text.ReplaceLineEndings();
			text = text.Replace( "    ", "\t" );
		}

		_contents = text;
		_textEdit.PlainText = _contents;
	}
}
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", true },
		{ "SupportsMaskedBlendMode", true },
		{ "SupportsTranslucentBlendMode", true },
		{ "SupportsDynamicBlendMode", 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;
	};
}