2409 results

global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
global using Dreams.UltimateLightManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Sandbox;

namespace Sandbox.AssetBrowserAddon;

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

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

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

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

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

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

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

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

        }

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

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

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

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

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

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

        return input;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace Editor.TerrainConvert;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

		ApplyNormalize( values, settings.Normalize );

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

		return Quantize( values, resolution, settings );
	}

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

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

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

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

		return dst;
	}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace Sandbox.Reactivity.Editor.Debugger;

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

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

		Effect.OnEffectRootCreated += OnEffectRootCreated;
	}

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

		_tree.Clear();

		Effect.OnEffectRootCreated -= OnEffectRootCreated;
	}

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

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

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

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

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

#endif
#if DEBUG

using Sandbox.Reactivity.Internals;

namespace Sandbox.Reactivity.Editor.Inspector;

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

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

	public override string DisplayName => Name;

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

	public override bool IsEditable => false;

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

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

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

#endif
using System.Text.Json.Serialization;

namespace Grains.RazorDesigner.Document;

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

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

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

namespace Grains.RazorDesigner.Inspector;

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

	public override bool SupportsMultiEdit => true;

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

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

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

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

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

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

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

		_proxySerialized.OnPropertyChanged += OnProxyChanged;

		SyncFromProperty();
	}

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

	protected override void PaintControl()
	{
		// nothing
	}

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

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

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

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

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

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

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

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

namespace Grains.RazorDesigner.Palette;

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

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

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

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

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

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

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

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

		Layout.Add( _templatesSection );

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

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

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

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

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

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

			Layout.Add( section );
		}

		Layout.AddStretchCell();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

public abstract record CSharpOp;

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

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

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

public sealed record MethodClose() : CSharpOp;

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

namespace Grains.RazorDesigner.Projection.CSharp;

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

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

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

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

        ops.Add( new ClassClose() );

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

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

namespace Grains.RazorDesigner.Projection.CSharp.Projectors;

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

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

namespace Grains.RazorDesigner.Projection;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace Grains.RazorDesigner.Projection.Projectors;

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

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

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

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

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

namespace Grains.RazorDesigner.Projection.Projectors;

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

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

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

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

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

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

namespace Grains.RazorDesigner.Projection.Tests;

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

namespace Grains.RazorDesigner.Serialization.IR;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace Grains.RazorDesigner.Wiring;

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

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

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

namespace Grains.RazorDesigner.Wiring;

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

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

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

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

namespace AutoRig.Editor.Gate;

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

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

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

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

        _ = RunAsync();
    }

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

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

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

        if ( Result.refusedWrongProject )
            return;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace AutoRig.Editor;

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

    SceneLineObject _wire;
    SceneLineObject _bones;

    RigMesh _mesh;
    RigResult _rig;

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

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

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

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

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

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

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

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

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

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

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

    SceneObject _solid;

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

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

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

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

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

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

    Material _texturedMaterial;
    bool _texturedTried;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        var picked = PickJoint( e.LocalPosition );

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        _yaw -= delta.x * 0.4f;
    }

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

namespace Editor.AutoRig.Vast;

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

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

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

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

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

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

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

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

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

namespace Editor.AutoRig.Vast;

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

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

    readonly VastClient _client;
    readonly Action<string> _progress;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace ProtonMcpBridge;

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

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

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

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

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

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

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

			await connect; // surface any connect exception

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

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

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

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

namespace GeneralGame.Editor;

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

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

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

        menu.AddSeparator();

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

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

        menu.AddSeparator();

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

        menu.AddSeparator();

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

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

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

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

        menu.AddSeparator();

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

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

        menu.AddSeparator();

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

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

        menu.AddSeparator();

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

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

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

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

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

            File.Copy(filePath, newPath);

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

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

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

        onComplete?.Invoke();

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

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

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

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

            try
            {
                File.Move(newPath, renamedPath);

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

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

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

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

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

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

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

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

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

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

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

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

        return candidate;
    }

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

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

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

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

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

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

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

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

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

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

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

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

                prefabRoot.Name = Path.GetFileNameWithoutExtension(location);

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

        onComplete?.Invoke();
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        var regex = BuildReferenceRegex(reference);

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

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

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

        return results;
    }

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

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

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

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

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

        return true;
    }

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

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

        File.Delete(fullPath);

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

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

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

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

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

        if (addedTypeOptions)
            menu.AddSeparator();

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

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

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

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

        bool added = false;

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

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

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

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

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

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

            added = true;
        }

        return added;
    }

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

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

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

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

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

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

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

        if (!fd.Execute())
            return;

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

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

        if (!fd.Execute())
            return;

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

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

        if (!fd.Execute())
            return;

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

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

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

        if (!fd.Execute())
            return;

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

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

        EditorUtility.CreateModelFromMeshFile(asset, targetPath);
    }

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

        if (!fd.Execute())
            return;

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

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

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

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

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

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

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

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

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

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

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

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

        EditorUtility.CreateModelFromMeshFile(asset, destPath);
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

}}
";
    }

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

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

}}
";
    }

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

        return Json.Serialize(vtexContent);
    }

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

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

        return Json.Serialize(content);
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

		y += 8;

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

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

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

		_cancelRect = cancelRect;
		_overwriteRect = overwriteRect;
	}

	private Rect _cancelRect;
	private Rect _overwriteRect;

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

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

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

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

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

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

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

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

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

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

	protected virtual void OnContentPaint()
	{
	}

	protected virtual void OnContentMousePress( MouseEvent e )
	{
	}

	protected virtual void OnContentMouseMove( MouseEvent e )
	{
	}

	protected virtual void OnContentMouseWheel( WheelEvent e )
	{
	}

	protected virtual void OnContentKeyPress( KeyEvent e )
	{
	}

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

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

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

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

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

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

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

namespace Editor.SuperShot;

public sealed class SettingsPanel : Widget
{
	readonly SuperShotWindow _window;

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

		Build();
	}

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

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

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

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

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

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

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

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

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

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

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

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

namespace Editor.SuperShot;

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

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

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

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

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

		layout.Add( group );
		return group;
	}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

public class TextureNodeType : ClassNodeType
{
	string ImagePath;

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

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

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

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

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

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

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

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

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

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

		node.Graph = graph;

		return node;
	}
}

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

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

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

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

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

		return node;
	}
}
namespace Editor.ShaderGraphExtras;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

		return features;
	}

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

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

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

		return features;
	}

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

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

					return (shadingModelCode, includePath);
				}
			}
		}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

	const float ANGLE_CONVERSION = 3.14159265 / 360.0;

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

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

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

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

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

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

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

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

	return rot;
}
";

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

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

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

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

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

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

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

	return result;
}
";

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

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

	return result;
}
";

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

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

	return result;
}
";

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

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

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

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

	private static Dictionary<string, string> Functions;

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

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

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

	static ShaderTemplate()
	{
		CreateFunctions();
	}

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

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

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

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

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

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

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

	#define S_UV2 1
}}

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

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

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

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

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

{9}{10}{11}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

	[Hide, JsonIgnore]
	int _lastHashCode = 0;

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

		var hashCode = new HashCode();
		hashCode.Add(Mode);
		hashCode.Add(Dimension);
		var hc = hashCode.ToHashCode();
		if (hc != _lastHashCode)
		{
			_lastHashCode = hc;
			CreateInputs();
			Update();
		}
	}

	private void CreateInputs()
	{
		var plugs = new List<IPlugIn>();
		var serialized = this.GetSerialized();
		foreach (var property in serialized)
		{
			if (property.TryGetAttribute<InputAttribute>(out var inputAttr))
			{
				if (property.TryGetAttribute<ConditionalVisibilityAttribute>(out var conditionalVisibilityAttr))
				{
					if (conditionalVisibilityAttr.TestCondition(this.GetSerialized()))
					{
						continue;
					}
				}
				var propertyInfo = typeof(SGENoiseNode).GetProperty(property.Name);
				if (propertyInfo is null) continue;
				var info = new PlugInfo(propertyInfo);
				var displayInfo = info.DisplayInfo;
				displayInfo.Name = property.DisplayName;
				info.DisplayInfo = displayInfo;

				// Try to find existing plug to preserve connections
				var oldPlug = Inputs.FirstOrDefault(x => x is BasePlugIn plugIn && plugIn.Info.Name == property.Name) as BasePlugIn;
				if (oldPlug is not null)
				{
					oldPlug.Info.Name = info.Name;
					oldPlug.Info.Type = info.Type;
					oldPlug.Info.DisplayInfo = info.DisplayInfo;
					plugs.Add(oldPlug);
				}
				else
				{
					var plug = new BasePlugIn(this, info, info.Type);
					plugs.Add(plug);
				}
			}
		}
		Inputs = plugs;
	}

	[Hide]
	private bool IsNoisefBMMode => Mode == SGENoiseMode.fBM;
	[Hide]
	private bool IsNoiseVoronoiMode => Mode == SGENoiseMode.Voronoi;

	[Hide]
	[Input(typeof(Vector3))]
	public NodeInput Coordinates { get; set; }

	[Hide]
	[Input(typeof(int))]
	[ShowIf(nameof(IsNoisefBMMode), true)]
	public NodeInput Octaves { get; set; }

	[InputDefault(nameof(Octaves))]
	[ShowIf(nameof(IsNoisefBMMode), true)]
	public float DefaultOctaves { get; set; } = 6;

	[Hide]
	[Input(typeof(float))]
	[ShowIf(nameof(IsNoiseVoronoiMode), true)]
	public NodeInput Offset { get; set; }

	[InputDefault(nameof(Offset))]
	[ShowIf(nameof(IsNoiseVoronoiMode), true)]
	public float DefaultOffset { get; set; } = 3.14159265359f;

	[Hide]
	[Output(typeof(float))]
	public NodeResult.Func Output => (GraphCompiler compiler) =>
	{
		var coordinates = compiler.Result(Coordinates);

		compiler.RegisterInclude("shaders/HLSL/Functions/FUNC-noise.hlsl");

		NodeResult result = new NodeResult();

		switch (Mode)
		{
			case SGENoiseMode.Static:
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEStaticNoise2D({coordinates})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEStaticNoise3D({coordinates})");
				}
				break;

			case SGENoiseMode.Value:
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEValueNoise2D({coordinates})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEValueNoise3D({coordinates})");
				}
				break;

			case SGENoiseMode.Simplex:
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGESimplexNoise2D({coordinates})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGESimplexNoise3D({coordinates})");
				}
				break;

			case SGENoiseMode.fBM:
				var octaves = compiler.ResultOrDefault(Octaves, DefaultOctaves);

				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEfBMNoise2D({coordinates}, {octaves})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEfBMNoise3D({coordinates}, {octaves})");
				}
				break;

			case SGENoiseMode.Voronoi:
				var offset = compiler.ResultOrDefault(Offset,DefaultOffset);
				if (Dimension == SGENoiseDimension.Noise2D)
				{
					result = new NodeResult(NodeResultType.Float, $"SGEVoronoiNoise2D({coordinates}, {offset})");
				}
				else
				{
					result = new NodeResult(NodeResultType.Float, $"SGEVoronoiNoise3D({coordinates}, {offset})");
				}
				break;
		}

		return result;
	};
}
namespace Editor.ShaderGraphExtras;

internal static class ComboControlWidgetHelper
{
	public static ShaderNode GetShaderNode( SerializedProperty property )
	{
		if ( property is null )
			return null;

		if ( property.Parent.Targets.First() is ShaderNode shaderNode )
			return shaderNode;

		return GetShaderNode( property.Parent?.ParentProperty );
	}

	public static void SetupComboBox<T>(
		ComboBox comboBox,
		SerializedProperty property,
		string currentValue,
		Func<SGEComboNode, string> getValue,
		Func<string, T> createValue )
	{
		List<string> namesSoFar = [currentValue];

		comboBox.AddItem( "" );
		if ( !string.IsNullOrEmpty( currentValue ) )
		{
			comboBox.AddItem( currentValue );
			comboBox.CurrentIndex = 1;
		}

		var parentNode = GetShaderNode( property );
		if ( parentNode is not null )
		{
			foreach ( var node in parentNode.Graph.Nodes )
			{
				if ( node is SGEComboNode comboNode )
				{
					string value = getValue( comboNode );
					if ( !string.IsNullOrEmpty( value ) && !namesSoFar.Contains( value ) )
					{
						comboBox.AddItem( value );
						namesSoFar.Add( value );
					}
				}
			}
		}

		comboBox.Editable = true;
		comboBox.Insertion = ComboBox.InsertMode.Skip;

		comboBox.TextChanged += () =>
		{
			property.SetValue( createValue( comboBox.CurrentText ) );
		};
	}
}

[CustomEditor( typeof( ComboName ) )]
internal class ComboNameControlWidget : ControlWidget
{
	public override bool SupportsMultiEdit => false;

	public ComboNameControlWidget( SerializedProperty property ) : base( property )
	{
		Layout = Layout.Row();
		var comboBox = Layout.Add( new ComboBox( this ) );
		var currentValue = SerializedProperty.GetValue<ComboName>().Name;

		ComboControlWidgetHelper.SetupComboBox(
			comboBox, property, currentValue,
			node => node.Name,
			text => new ComboName { Name = text } );
	}
}

[CustomEditor( typeof( ComboGroup ) )]
internal class ComboGroupControlWidget : ControlWidget
{
	public override bool SupportsMultiEdit => false;

	public ComboGroupControlWidget( SerializedProperty property ) : base( property )
	{
		Layout = Layout.Row();
		var comboBox = Layout.Add( new ComboBox( this ) );
		var currentValue = SerializedProperty.GetValue<ComboGroup>().Group;

		ComboControlWidgetHelper.SetupComboBox(
			comboBox, property, currentValue,
			node => node.Group,
			text => new ComboGroup { Group = text } );
	}
}
namespace Editor.ShaderGraphExtras;

[CustomEditor( typeof( string ), NamedEditor = "shadergraphgroup" )]
internal class ShaderGraphGroupControlWidget : ControlWidget
{
	public override bool SupportsMultiEdit => false;

	ComboBox _comboBox;

	public ShaderGraphGroupControlWidget( SerializedProperty property ) : base( property )
	{
		Layout = Layout.Row();

		_comboBox = Layout.Add( new ComboBox( this ) );

		var currentVal = SerializedProperty.GetValue<string>();
		List<string> namesSoFar = [currentVal];

		_comboBox.AddItem( "" );
		if ( !string.IsNullOrEmpty( currentVal ) )
		{
			_comboBox.AddItem( currentVal );
			_comboBox.CurrentIndex = 1;
		}

		var groupProperty = GetGroupProperty( property );
		var parentNode = GetShaderNode( property );
		if ( groupProperty is not null && parentNode is not null )
		{
			foreach ( var node in parentNode.Graph.Nodes )
			{
				var serialized = node.GetSerialized();
				foreach ( var prop in serialized )
				{
					if ( prop.PropertyType == typeof( ParameterUI ) || prop.PropertyType == typeof( TextureInput ) )
					{
						if ( prop.TryGetAsObject( out var propObj ) )
						{
							// Get same property name so groups only show group names, sub-groups only show sub-group names, ect
							var innerProp = propObj.GetProperty( groupProperty?.Name );
							var groupVal = innerProp?.GetValue<UIGroup>();
							if ( !string.IsNullOrEmpty( groupVal?.Name ) && !namesSoFar.Contains( groupVal?.Name ) )
							{
								_comboBox.AddItem( groupVal?.Name );
								namesSoFar.Add( groupVal?.Name );
							}
						}
					}
				}
			}
		}

		_comboBox.Editable = true;
		_comboBox.Insertion = ComboBox.InsertMode.Skip;

		_comboBox.TextChanged += () =>
		{
			SerializedProperty.SetValue<string>( _comboBox.CurrentText );
		};
	}

	SerializedProperty GetGroupProperty( SerializedProperty originalProperty )
	{
		if ( originalProperty is null )
		{
			return null;
		}
		if ( originalProperty.PropertyType == typeof( UIGroup ) )
		{
			return originalProperty;
		}
		return GetGroupProperty( originalProperty.Parent?.ParentProperty );
	}

	ShaderNode GetShaderNode( SerializedProperty originalProperty )
	{
		if ( originalProperty is null )
		{
			return null;
		}
		if ( originalProperty.Parent.Targets.First() is ShaderNode shaderNode )
		{
			return shaderNode;
		}
		return GetShaderNode( originalProperty.Parent?.ParentProperty );
	}
}

namespace Editor.ShaderGraphExtras;

internal class GamePerformanceBar : Widget
{
	private readonly Func<string> _getValue;
	private RealTimeSince _timeSinceUpdate;

	public GamePerformanceBar( Func<string> val ) : base( null )
	{
		_getValue = val;

		MinimumHeight = Theme.RowHeight;
		MinimumWidth = 60;
	}

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

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

		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( LocalRect, Theme.ControlRadius );

		Paint.SetPen( Theme.Green.WithAlpha( 0.4f ) );
		Paint.DrawText( LocalRect.Shrink( 8, 0 ), _getValue(), TextFlag.RightCenter );
	}

	[EditorEvent.Frame]
	public void Frame()
	{
		if ( _timeSinceUpdate < 0.6f )
			return;

		_timeSinceUpdate = Random.Shared.Float( 0, 0.1f );

		Update();
	}
}
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;

namespace HammerTextureBrowser;

[Dock( "Editor", "OG Texture Browser", "texture", DockArea.Bottom )]
public sealed class HammerTextureBrowserDock : Widget, AssetSystem.IEventListener
{
	private static readonly Regex QuotedMaterialProperty = new( "\"(?<key>[^\"]+)\"\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant );
	private static readonly string[] PrimaryTextureKeys =
	[
		"TextureColor",
		"TextureBaseColor",
		"TextureAlbedo",
		"AlbedoTexture",
		"BaseTexture",
		"BaseColorTexture",
		"g_tColor",
		"g_tBaseColor",
		"g_tAlbedo"
	];

	private readonly HammerTextureList TextureList;
	private readonly ComboBox SizeCombo;
	private readonly LineEdit FilterEdit;
	private readonly ComboBox KeywordsCombo;
	private readonly Checkbox OnlyUsedTextures;
	private readonly Label SelectedTextureLabel;
	private readonly Label TextureSizeLabel;
	private readonly Label CountLabel;
	private readonly Button MarkButton;
	private readonly Button ReplaceButton;
	private readonly Button ReloadButton;
	private readonly Button OpenSourceButton;

	private List<Asset> AllMaterials = new();
	private Asset SelectedMaterial;
	private string KeywordFilter = string.Empty;
	private int PreviewSize = 128;

	public HammerTextureBrowserDock( Widget parent ) : base( parent )
	{
		MinimumSize = new( 420, 320 );
		WindowTitle = "OG Texture Browser";
		SetWindowIcon( "texture" );

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		TextureList = Layout.Add( new HammerTextureList( this ), 1 );
		TextureList.OnTextureSelected = SelectMaterial;
		TextureList.OnTextureActivated = SelectMaterial;
		TextureList.OnOpenInEditor = OpenMaterialSource;
		TextureList.OnOpenInAssetBrowser = OpenInAssetBrowser;

		var bottom = Layout.Add( new Widget( this ) );
		bottom.Layout = Layout.Column();
		bottom.Layout.Margin = 4;
		bottom.Layout.Spacing = 3;
		bottom.FixedHeight = Theme.RowHeight * 2 + 12;
		bottom.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground );
			Paint.DrawRect( bottom.LocalRect );
			return false;
		};

		var topRow = bottom.Layout.AddRow();
		topRow.Spacing = 5;

		var sizeBlock = topRow.Add( new Widget( this ) );
		sizeBlock.FixedWidth = 138;
		sizeBlock.MinimumWidth = 138;
		sizeBlock.MaximumWidth = 138;
		sizeBlock.Layout = Layout.Row();
		sizeBlock.Layout.Margin = 0;
		sizeBlock.Layout.Spacing = 5;

		AddSmallLabel( sizeBlock.Layout, "Size:" );
		SizeCombo = sizeBlock.Layout.Add( new ComboBox( sizeBlock ) );
		SizeCombo.FixedWidth = 88;
		SizeCombo.MinimumWidth = 88;
		SizeCombo.MaximumWidth = 88;
		SizeCombo.AddItem( "32x32", null, () => SetPreviewSize( 32 ) );
		SizeCombo.AddItem( "64x64", null, () => SetPreviewSize( 64 ) );
		SizeCombo.AddItem( "128x128", null, () => SetPreviewSize( 128 ) );
		SizeCombo.AddItem( "256x256", null, () => SetPreviewSize( 256 ) );
		SizeCombo.AddItem( "1:1", null, () => SetPreviewSize( 128 ) );
		SizeCombo.CurrentIndex = 2;
		StyleBottomInput( SizeCombo, 24 );

		AddSmallLabel( topRow, "Filter:", 58 );
		FilterEdit = topRow.Add( new LineEdit( this ) );
		FilterEdit.FixedWidth = 176;
		FilterEdit.MinimumWidth = 176;
		FilterEdit.MaximumWidth = 176;
		FilterEdit.PlaceholderText = "texture name";
		FilterEdit.TextChanged += _ => RefreshList();
		StyleBottomInput( FilterEdit );

		SelectedTextureLabel = topRow.Add( new Label( this ) );
		SelectedTextureLabel.MinimumWidth = 180;
		SelectedTextureLabel.Alignment = TextFlag.LeftCenter;
		SelectedTextureLabel.Text = "";

		OpenSourceButton = topRow.Add( new Button( "Open Source" ) );
		OpenSourceButton.FixedWidth = 96;
		OpenSourceButton.Clicked = OpenSelectedSource;

		var bottomRow = bottom.Layout.AddRow();
		bottomRow.Spacing = 5;
		OnlyUsedTextures = bottomRow.Add( new Checkbox( "Only used textures" ) );
		OnlyUsedTextures.FixedWidth = 138;
		OnlyUsedTextures.StateChanged += _ => RefreshList();

		AddSmallLabel( bottomRow, "Keywords:", 58 );
		KeywordsCombo = bottomRow.Add( new ComboBox( this ) );
		KeywordsCombo.FixedWidth = 176;
		KeywordsCombo.MinimumWidth = 176;
		KeywordsCombo.MaximumWidth = 176;
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
		StyleBottomInput( KeywordsCombo, 24 );

		MarkButton = bottomRow.Add( new Button( "Mark" ) );
		MarkButton.FixedWidth = 76;
		MarkButton.Clicked = MarkSelectedMaterial;

		ReplaceButton = bottomRow.Add( new Button( "Replace" ) );
		ReplaceButton.FixedWidth = 76;
		ReplaceButton.Clicked = ReplaceSelectedMaterial;

		ReloadButton = bottomRow.Add( new Button( "Reload" ) );
		ReloadButton.FixedWidth = 76;
		ReloadButton.Clicked = Reload;

		TextureSizeLabel = bottomRow.Add( new Label( this ) );
		TextureSizeLabel.MinimumWidth = 0;
		TextureSizeLabel.MaximumWidth = 68;
		TextureSizeLabel.Alignment = TextFlag.RightCenter;

		bottomRow.AddStretchCell( 1 );

		CountLabel = bottomRow.Add( new Label( this ) );
		CountLabel.MinimumWidth = 0;
		CountLabel.MaximumWidth = 96;
		CountLabel.Alignment = TextFlag.RightCenter;

		ReloadMaterials();
		SetPreviewSize( PreviewSize );
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	private void Reload()
	{
		ReloadMaterials();
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	void AssetSystem.IEventListener.OnAssetSystemChanges()
	{
		Reload();
	}

	void AssetSystem.IEventListener.OnAssetTagsChanged()
	{
		ReloadMaterials();
		RefreshList();
	}

	void AssetSystem.IEventListener.OnAssetChanged( Asset asset )
	{
		if ( asset?.AssetType != AssetType.Material )
			return;

		ReloadMaterials();
		RefreshList();

		if ( asset == SelectedMaterial )
			UpdateSelectedMaterialUi();
	}

	private static void AddSmallLabel( Layout row, string text, int fixedWidth = 0 )
	{
		var label = row.Add( new Label( text ) );
		label.Alignment = fixedWidth > 0 ? TextFlag.RightCenter : TextFlag.LeftCenter;
		label.FixedWidth = fixedWidth > 0 ? fixedWidth : text.Length * 6 + 4;
	}

	private static void StyleBottomInput( Widget widget, int fixedHeight = 22 )
	{
		widget.FixedHeight = fixedHeight;
		widget.SetStyles(
			"background-color: #2d3136; " +
			"border: 1px solid #555c64; " +
			"border-radius: 2px; " +
			"color: #f2f2f2; " +
			"padding-top: 0px; " +
			"padding-bottom: 0px; " +
			"padding-left: 5px; " +
			"padding-right: 5px;" );
	}

	private void ReloadMaterials()
	{
		var menuPath = EditorUtility.Projects.GetAll()
			.FirstOrDefault( x => x.Config.Ident == "menu" )
			?.GetAssetsPath()
			.NormalizeFilename( false );

		AllMaterials = AssetSystem.All
			.Where( IsBrowsableMaterial )
			.Where( x => !IsMenuAsset( x, menuPath ) )
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.ToList();

		RebuildKeywords();
	}

	private static bool IsBrowsableMaterial( Asset asset )
	{
		if ( asset is null )
			return false;

		if ( asset.AssetType != AssetType.Material )
			return false;

		if ( asset.AbsolutePath?.Contains( ".sbox/cloud/", StringComparison.OrdinalIgnoreCase ) ?? false )
			return false;

		return true;
	}

	private static bool IsMenuAsset( Asset asset, string menuPath )
	{
		if ( string.IsNullOrEmpty( menuPath ) )
			return false;

		return asset.AbsolutePath?.NormalizeFilename( false ).StartsWith( menuPath, StringComparison.OrdinalIgnoreCase ) ?? false;
	}

	private void RebuildKeywords()
	{
		var tags = AllMaterials
			.SelectMany( x => x.Tags )
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
			.ToList();

		KeywordsCombo.Clear();
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );

		foreach ( var tag in tags )
		{
			var tagValue = tag;
			KeywordsCombo.AddItem( tagValue, null, () => SetKeywordFilter( tagValue ) );
		}

		KeywordsCombo.CurrentIndex = 0;
	}

	private void SetKeywordFilter( string tag )
	{
		KeywordFilter = tag ?? string.Empty;
		RefreshList();
	}

	private void SetPreviewSize( int size )
	{
		PreviewSize = size;
		TextureList.SetPreviewSize( PreviewSize );
		RefreshList();
	}

	private void RefreshList()
	{
		if ( TextureList is null )
			return;

		IEnumerable<Asset> materials = AllMaterials;

		var query = FilterEdit?.Text ?? string.Empty;
		if ( !string.IsNullOrWhiteSpace( query ) )
		{
			var parts = query.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
			materials = materials.Where( x => MatchesQuery( x, parts ) );
		}

		if ( !string.IsNullOrWhiteSpace( KeywordFilter ) )
		{
			materials = materials.Where( x => x.Tags.Any( tag => tag.Equals( KeywordFilter, StringComparison.OrdinalIgnoreCase ) ) );
		}

		if ( OnlyUsedTextures?.Value ?? false )
		{
			var used = HammerMaterialSelection.GetUsedMaterialKeys();
			materials = materials.Where( x => used.Contains( x.RelativePath ) || used.Contains( x.AbsolutePath ) || used.Contains( TextureDisplayName( x ) ) );
		}

		var entries = materials
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.Select( x => new HammerTextureEntry( x ) )
			.ToList();

		TextureList.SetItems( entries );
		if ( CountLabel is not null )
		{
			CountLabel.Text = $"{entries.Count:n0} textures";
			CountLabel.Update();
		}

		if ( SelectedMaterial is not null && entries.All( x => x.Asset != SelectedMaterial ) )
			TextureList.UnselectAll();
	}

	private static bool MatchesQuery( Asset asset, IEnumerable<string> parts )
	{
		var name = TextureDisplayName( asset );
		var type = asset.AssetType?.FriendlyName ?? string.Empty;
		IEnumerable<string> tags = asset.Tags;

		foreach ( var part in parts )
		{
			var search = part;
			var negated = false;
			if ( search.StartsWith( "-" ) )
			{
				search = search[1..];
				negated = true;
			}

			var matched =
				name.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				type.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				tags.Any( x => x.Contains( search, StringComparison.OrdinalIgnoreCase ) );

			if ( !negated && !matched )
				return false;

			if ( negated && matched )
				return false;
		}

		return true;
	}

	private void SelectMaterial( Asset material )
	{
		if ( material?.AssetType != AssetType.Material )
			return;

		SelectedMaterial = material;
		EditorUtility.InspectorObject = material;
		HammerMaterialSelection.SetCurrentMaterial( material );
		UpdateSelectedMaterialUi();
	}

	private void UpdateSelectedMaterialUi()
	{
		var hasMaterial = SelectedMaterial is not null;

		SelectedTextureLabel.Text = hasMaterial ? TextureDisplayName( SelectedMaterial ) : "";
		SelectedTextureLabel.ToolTip = SelectedMaterial?.RelativePath ?? string.Empty;

		TextureSizeLabel.Text = GetTextureSizeText( SelectedMaterial );
		TextureSizeLabel.ToolTip = SelectedMaterial?.AbsolutePath ?? string.Empty;

		MarkButton.Enabled = hasMaterial;
		ReplaceButton.Enabled = hasMaterial;
		OpenSourceButton.Enabled = hasMaterial;

		SelectedTextureLabel.Update();
		TextureSizeLabel.Update();
		MarkButton.Update();
		ReplaceButton.Update();
		OpenSourceButton.Update();
	}

	private static string GetTextureSizeText( Asset asset )
	{
		if ( asset is null )
			return "";

		var texturePath = GetPrimaryMaterialTexturePath( asset );
		if ( string.IsNullOrWhiteSpace( texturePath ) )
			return "";

		try
		{
			var texture = Texture.Load( texturePath );
			if ( texture is null || !texture.IsValid() || texture.Width <= 0 || texture.Height <= 0 )
				return "";

			return $"{texture.Width}x{texture.Height}";
		}
		catch
		{
			return "";
		}
	}

	private static string GetPrimaryMaterialTexturePath( Asset asset )
	{
		if ( string.IsNullOrWhiteSpace( asset?.AbsolutePath ) || !File.Exists( asset.AbsolutePath ) )
			return null;

		try
		{
			var properties = QuotedMaterialProperty.Matches( File.ReadAllText( asset.AbsolutePath ) )
				.Select( x => (Key: x.Groups["key"].Value, Value: NormalizeTexturePath( x.Groups["value"].Value )) )
				.Where( x => IsTexturePath( x.Value ) )
				.ToList();

			foreach ( var key in PrimaryTextureKeys )
			{
				var match = properties.FirstOrDefault( x => x.Key.Equals( key, StringComparison.OrdinalIgnoreCase ) );
				if ( !string.IsNullOrWhiteSpace( match.Value ) )
					return match.Value;
			}

			var colorMatch = properties.FirstOrDefault( x =>
				(x.Key.Contains( "color", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "albedo", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "base", StringComparison.OrdinalIgnoreCase )) &&
				!x.Key.Contains( "tint", StringComparison.OrdinalIgnoreCase ) );

			if ( !string.IsNullOrWhiteSpace( colorMatch.Value ) )
				return colorMatch.Value;

			return properties.FirstOrDefault().Value;
		}
		catch
		{
			return null;
		}
	}

	private static string NormalizeTexturePath( string path )
	{
		return path?.Trim().Replace( '\\', '/' );
	}

	private static bool IsTexturePath( string path )
	{
		var extension = Path.GetExtension( path );
		return extension.Equals( ".vtex", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tga", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".png", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpeg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tif", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tiff", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".psd", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".exr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".hdr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".bmp", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".webp", StringComparison.OrdinalIgnoreCase );
	}

	private void MarkSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.SelectFacesUsingMaterial( SelectedMaterial );
	}

	private void ReplaceSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.AssignAssetToSelection( SelectedMaterial );
	}

	private void OpenSelectedSource()
	{
		OpenMaterialSource( SelectedMaterial );
	}

	private void OpenMaterialSource( Asset asset )
	{
		if ( asset is null )
			return;

		if ( asset.CanOpenInEditor )
		{
			asset.OpenInEditor();
			return;
		}

		EditorUtility.OpenFileFolder( asset.AbsolutePath );
	}

	private void OpenInAssetBrowser( Asset asset )
	{
		if ( asset is null )
			return;

		LocalAssetBrowser.OpenTo( asset, true );
	}

	internal static string TextureDisplayName( Asset asset )
	{
		var path = asset?.RelativePath ?? asset?.Name ?? "";
		path = path.NormalizeFilename( false, false );

		if ( path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
			path = path["materials/".Length..];

		var extension = Path.GetExtension( path );
		if ( !string.IsNullOrEmpty( extension ) )
			path = path[..^extension.Length];

		return path;
	}
}

internal sealed class HammerTextureList : ListView, AssetSystem.IEventListener
{
	private int PreviewSize = 128;

	public Action<Asset> OnTextureSelected;
	public Action<Asset> OnTextureActivated;
	public Action<Asset> OnOpenInEditor;
	public Action<Asset> OnOpenInAssetBrowser;

	public HammerTextureList( Widget parent ) : base( parent )
	{
		MultiSelect = false;
		FocusMode = FocusMode.TabOrClick;
		Margin = 2;
		ItemSpacing = 2;
		ItemPaint = PaintTextureItem;
		ItemSelected = item => SelectTexture( item, false );
		ItemActivated = item => SelectTexture( item, true );
		ItemDrag = StartTextureDrag;
		ItemContextMenu = OpenTextureContextMenu;
		ItemScrollEnter = item => (item as HammerTextureEntry)?.OnScrollEnter();
		ItemScrollExit = item => (item as HammerTextureEntry)?.OnScrollExit();
		OnPaintOverride = PaintBackground;
	}

	public void SetPreviewSize( int previewSize )
	{
		PreviewSize = previewSize;
		ItemSize = new Vector2( PreviewSize + 4, PreviewSize + 18 );
		Update();
	}

	public void SetItems( IEnumerable<HammerTextureEntry> entries )
	{
		base.SetItems( entries.Cast<object>() );
		Update();
	}

	void AssetSystem.IEventListener.OnAssetThumbGenerated( Asset asset )
	{
		Rebuild();
	}

	private bool PaintBackground()
	{
		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( LocalRect );
		return false;
	}

	private void SelectTexture( object item, bool activated )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		if ( activated )
			OnTextureActivated?.Invoke( entry.Asset );
		else
			OnTextureSelected?.Invoke( entry.Asset );
	}

	private bool StartTextureDrag( object item )
	{
		if ( item is not HammerTextureEntry entry || entry.Asset is null )
			return false;

		SelectItem( entry );
		SelectTexture( entry, false );

		var drag = new Drag( this );
		drag.Data.Object = entry.Asset;
		drag.Data.Text = entry.Asset.RelativePath;
		drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );

		foreach ( var selected in SelectedItems.OfType<HammerTextureEntry>() )
		{
			if ( selected == entry || selected.Asset is null )
				continue;

			drag.Data.Text += "\n" + selected.Asset.RelativePath;
		}

		drag.Execute();
		return true;
	}

	private void OpenTextureContextMenu( object item )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		SelectItem( entry );
		SelectTexture( entry, false );

		var menu = new ContextMenu( this );
		menu.AddOption( "Open in Editor", "edit", () => OnOpenInEditor?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.AddOption( "Open in Asset Browser", "search", () => OnOpenInAssetBrowser?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.OpenAtCursor( false );
	}

	private void PaintTextureItem( VirtualWidget item )
	{
		if ( item.Object is not HammerTextureEntry entry )
			return;

		var rect = item.Rect.Shrink( 1 );
		var active = Paint.HasPressed;
		var selected = Paint.HasSelected || Paint.HasPressed;
		var hover = !selected && Paint.HasMouseOver;

		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( rect );

		if ( selected )
		{
			Paint.SetPen( Theme.Primary, 2 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}
		else if ( hover )
		{
			Paint.SetPen( Theme.ControlBackground.Lighten( 0.35f ), 1 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}

		var previewRect = rect;
		previewRect.Width = PreviewSize;
		previewRect.Height = PreviewSize;
		previewRect.Left += MathF.Max( 0, (rect.Width - PreviewSize) * 0.5f );

		entry.DrawPreview( previewRect, active );

		var nameRect = previewRect;
		nameRect.Top = previewRect.Bottom;
		nameRect.Height = 12;

		Paint.ClearPen();
		Paint.SetBrush( selected ? Theme.Primary.Lighten( active ? -0.1f : 0.05f ) : new Color( 0.0f, 0.05f, 0.85f ) );
		Paint.DrawRect( nameRect );

		Paint.SetDefaultFont( 6 );
		Paint.SetPen( Color.White );
		var label = Paint.GetElidedText( entry.DisplayName, nameRect.Width - 4, ElideMode.Middle );
		Paint.DrawText( nameRect.Shrink( 2, 0 ), label, TextFlag.LeftCenter | TextFlag.SingleLine );
	}
}

internal sealed class HammerTextureEntry
{
	public Asset Asset { get; }
	public string DisplayName { get; }

	public HammerTextureEntry( Asset asset )
	{
		Asset = asset;
		DisplayName = HammerTextureBrowserDock.TextureDisplayName( asset );
	}

	public void OnScrollEnter()
	{
		if ( Asset is null || Asset.HasCachedThumbnail )
			return;

		EditorEvent.Register( this );
		Asset.GetAssetThumb( true );
	}

	public void OnScrollExit()
	{
		if ( Asset is null )
			return;

		Asset.CancelThumbBuild();
		EditorEvent.Unregister( this );
	}

	public void DrawPreview( Rect rect, bool active )
	{
		Paint.ClearPen();
		Paint.SetBrush( active ? Color.Black.Lighten( 0.08f ) : Color.Black );
		Paint.DrawRect( rect );

		var thumb = Asset?.GetAssetThumb( true ) ?? AssetType.Material?.Icon128;
		if ( thumb is null )
			return;

		var drawRect = rect;
		var scale = MathF.Min( rect.Width / thumb.Width, rect.Height / thumb.Height );
		if ( scale > 0 && float.IsFinite( scale ) )
		{
			drawRect.Width = MathF.Min( rect.Width, thumb.Width * scale );
			drawRect.Height = MathF.Min( rect.Height, thumb.Height * scale );
			drawRect.Left = rect.Left + (rect.Width - drawRect.Width) * 0.5f;
			drawRect.Top = rect.Top + (rect.Height - drawRect.Height) * 0.5f;
		}

		Paint.BilinearFiltering = true;
		Paint.Draw( drawRect, thumb );
		Paint.BilinearFiltering = false;
	}
}

internal static class HammerMaterialSelection
{
	private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
	private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

	private static Type HammerType;
	private static bool HasResolvedHammerType;

	public static void SetCurrentMaterial( Asset asset ) => InvokeHammerAssetMethod( "SetCurrentMaterial", asset );
	public static void SelectFacesUsingMaterial( Asset asset ) => InvokeHammerAssetMethod( "SelectFacesUsingMaterial", asset );
	public static void AssignAssetToSelection( Asset asset ) => InvokeHammerAssetMethod( "AssignAssetToSelection", asset );

	public static HashSet<string> GetUsedMaterialKeys()
	{
		var keys = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		try
		{
			var activeMap = GetHammerType()?.GetProperty( "ActiveMap", StaticFlags )?.GetValue( null );
			if ( activeMap is null )
				return keys;

			var world = activeMap.GetType().GetProperty( "World", InstanceFlags )?.GetValue( activeMap );
			if ( world is null )
				return keys;

			CollectUsedMaterialKeys( world, keys );
		}
		catch
		{
			// Hammer throws when no map is open; in that case "Only used textures" should simply show none.
			return keys;
		}

		return keys;
	}

	private static void CollectUsedMaterialKeys( object node, HashSet<string> keys )
	{
		if ( node is null )
			return;

		var materialMethod = node.GetType().GetMethod( "GetFaceMaterialAssets", InstanceFlags );
		if ( materialMethod is not null && materialMethod.Invoke( node, null ) is IEnumerable materials )
		{
			foreach ( var item in materials )
			{
				if ( item is not Asset asset )
					continue;

				keys.Add( asset.RelativePath );
				keys.Add( asset.AbsolutePath );
				keys.Add( HammerTextureBrowserDock.TextureDisplayName( asset ) );
			}
		}

		var children = node.GetType().GetProperty( "Children", InstanceFlags )?.GetValue( node ) as IEnumerable;
		if ( children is null )
			return;

		foreach ( var child in children )
		{
			CollectUsedMaterialKeys( child, keys );
		}
	}

	private static void InvokeHammerAssetMethod( string methodName, Asset asset )
	{
		if ( asset is null )
			return;

		var method = GetHammerType()?.GetMethod( methodName, StaticFlags, binder: null, types: new[] { typeof( Asset ) }, modifiers: null );
		if ( method is null )
			return;

		try
		{
			method.Invoke( null, new object[] { asset } );
		}
		catch
		{
			// This dock still works as a browser if Hammer is not the active editor surface.
		}
	}

	private static Type GetHammerType()
	{
		if ( HasResolvedHammerType )
			return HammerType;

		HasResolvedHammerType = true;

		foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
		{
			HammerType = assembly.GetType( "Editor.MapEditor.Hammer" );
			if ( HammerType is not null )
				break;
		}

		return HammerType;
	}
}
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

internal static class WeaponMaterialPipeline
{
	private const string PreviewMaterialFormatVersion = "preview-material-v2";

	private static readonly HashSet<string> SupportedImageExtensions =
		new( StringComparer.OrdinalIgnoreCase )
		{
			".png", ".tga", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".dds", ".exr"
		};

	private static readonly string[] NearbyFolderNames =
		["textures", "texture", "materials", "material", "maps"];

	private sealed record TextureCandidate(
		string Path,
		string GroupName,
		WeaponTextureChannel Channel,
		int Priority );

	internal sealed record GeneratedTextureCopy(
		string RelativePath,
		string SourceAbsolute );

	public static List<SourceMaterialBinding> DiscoverAndPreparePreview(
		string absoluteSource,
		string cacheRoot,
		Asset modelAsset,
		Model? model,
		List<RigAuditIssue> issues,
		IEnumerable<string>? knownMaterialSlots = null )
	{
		var candidates = DiscoverTextureCandidates( absoluteSource );
		var slots = DiscoverMaterialSlots( modelAsset, model );
		slots.AddRange( knownMaterialSlots?
			.Where( slot => !string.IsNullOrWhiteSpace( slot ) )
			?? [] );
		var embeddedSlots = DiscoverEmbeddedMaterialNames(
			absoluteSource,
			candidates.Select( candidate => candidate.GroupName ) )
			.Select( name => $"{name}.vmat" )
			.ToArray();
		slots.AddRange( embeddedSlots );
		var embeddedSet = embeddedSlots.ToHashSet( StringComparer.OrdinalIgnoreCase );
		slots = slots
			.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )
			.GroupBy( slot => NormalizeName( Path.GetFileNameWithoutExtension( slot ) ) )
			.Select( group => group.FirstOrDefault( embeddedSet.Contains ) ?? group.First() )
			.ToList();
		if ( slots.Count == 0 )
		{
			// Some interchange compilers omit unresolved material metadata. Texture set names
			// are the best deterministic fallback for the original slot labels.
			slots.AddRange( candidates
				.Select( candidate => candidate.GroupName )
				.Distinct( StringComparer.OrdinalIgnoreCase )
				.Select( name => $"{name}.vmat" ) );
		}

		var groups = candidates
			.GroupBy( candidate => NormalizeName( candidate.GroupName ) )
			.Where( group => !string.IsNullOrWhiteSpace( group.Key ) )
			.ToDictionary(
				group => group.Key,
				group => group.ToArray(),
				StringComparer.OrdinalIgnoreCase );
		var bindings = new List<SourceMaterialBinding>();
		var usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var slot in slots
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( name => name, StringComparer.OrdinalIgnoreCase ) )
		{
			var displayName = Path.GetFileNameWithoutExtension( slot );
			var outputName = UniqueOutputName(
				WeaponAnimationDocument.Slugify( displayName ),
				usedNames );
			var binding = new SourceMaterialBinding
			{
				SourceMaterialPath = StoredMaterialSlot( slot ),
				Name = displayName,
				OutputName = outputName
			};

			var matchingGroup = FindBestGroup( displayName, groups );
			if ( matchingGroup is not null )
			{
				foreach ( var channelGroup in matchingGroup
					.GroupBy( candidate => candidate.Channel )
					.OrderBy( group => group.Key ) )
				{
					var candidate = channelGroup
						.OrderByDescending( item => item.Priority )
						.ThenBy( item => item.Path, StringComparer.OrdinalIgnoreCase )
						.First();
					var hash = WeaponSourceImporter.HashFile( candidate.Path );
					var assetPath = EnsureTextureInsideAssets(
						candidate.Path,
						cacheRoot,
						hash );
					binding.Textures.Add( new SourceTextureMap
					{
						Channel = candidate.Channel,
						OriginalPath = candidate.Path,
						AssetPath = assetPath,
						Sha256 = hash
					} );
				}
			}

			if ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null
				&& !binding.HasUsableTextures )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.packed_orm",
					Message =
						$"Material '{displayName}' only has a packed ORM texture. "
						+ "Separate color, normal, roughness, or metalness maps are required "
						+ "for automatic assignment.",
					Severity = ValidationSeverity.Warning
				} );
			}
			else if ( !binding.HasUsableTextures )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.textures_missing",
					Message =
						$"No nearby texture set matched material '{displayName}'. "
						+ "That slot will use the default material.",
					Severity = ValidationSeverity.Warning
				} );
			}
			else if ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null
				&& binding.FindTexture( WeaponTextureChannel.Roughness ) is null
				&& binding.FindTexture( WeaponTextureChannel.Metalness ) is null )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.packed_orm",
					Message =
						$"Material '{displayName}' only has a packed ORM texture. "
						+ "Separate roughness and metalness maps are required for automatic assignment.",
					Severity = ValidationSeverity.Warning
				} );
			}

			bindings.Add( binding );
		}

		PreparePreviewAssets( bindings, cacheRoot );
		return bindings;
	}

	public static IReadOnlyList<HostMaterialRemap> PreviewRemaps(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )
			.Select( binding => new HostMaterialRemap(
				ResourceMaterialSlot( binding.SourceMaterialPath ),
				binding.HasUsableTextures
					&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )
						? binding.PreviewMaterialPath
						: "materials/default.vmat" ) )
			.ToArray();

	public static IReadOnlyList<HostMaterialRemap> OutputRemaps(
		WeaponAnimationDocument document,
		string relativeRoot )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		return document.Source.Materials
			.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )
			.Select( binding => new HostMaterialRemap(
				ResourceMaterialSlot( binding.SourceMaterialPath ),
				binding.HasUsableTextures
					? $"{relativeRoot}/materials/{slug}_{binding.OutputName}.vmat"
					: "materials/default.vmat" ) )
			.ToArray();
	}

	public static bool RequiresPreviewRefresh( WeaponAnimationDocument document ) =>
		document.Source.NeedsModelDocWrapper
		&& (document.Source.Materials.Count == 0
			|| document.Source.CompiledModelPath.StartsWith(
				".weaponanim-cache/",
				StringComparison.OrdinalIgnoreCase )
			|| document.Source.Materials.Any( binding =>
				Path.GetExtension( binding.SourceMaterialPath ).Equals(
					".vmat",
					StringComparison.OrdinalIgnoreCase ) )
			|| document.Source.Materials.Any( binding =>
				binding.HasUsableTextures
				&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )
				&& (binding.PreviewMaterialPath.StartsWith(
						".weaponanim-cache/",
						StringComparison.OrdinalIgnoreCase )
					|| binding.PreviewMaterialPath.Contains(
						"/texture-definitions/",
						StringComparison.OrdinalIgnoreCase )) ));

	public static Dictionary<string, string> BuildOutputTextFiles(
		WeaponAnimationDocument document,
		string relativeRoot )
	{
		var files = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		foreach ( var binding in document.Source.Materials
			.Where( binding => binding.HasUsableTextures ) )
		{
			var texturePaths = new Dictionary<WeaponTextureChannel, string>();
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				var imageName = OutputTextureImageName( slug, binding, texture );
				var imageRelative = $"textures/{imageName}";
				var texturePath = $"{relativeRoot}/{imageRelative}";
				texturePaths[texture.Channel] = texturePath;
			}

			files[$"materials/{slug}_{binding.OutputName}.vmat"] =
				WriteVmat( texturePaths );
		}

		return files;
	}

	public static IReadOnlyList<GeneratedTextureCopy> BuildOutputTextureCopies(
		WeaponAnimationDocument document )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		var copies = new List<GeneratedTextureCopy>();
		foreach ( var binding in document.Source.Materials
			.Where( binding => binding.HasUsableTextures ) )
		{
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				var source = ResolveTextureAbsolute( texture );
				copies.Add( new GeneratedTextureCopy(
					$"textures/{OutputTextureImageName( slug, binding, texture )}",
					source ) );
			}
		}

		return copies;
	}

	internal static IReadOnlyList<SourceMaterialBinding> DiscoverForTests(
		IEnumerable<string> materialSlots,
		IEnumerable<string> texturePaths )
	{
		var candidates = texturePaths
			.Select( TryCreateCandidate )
			.Where( candidate => candidate is not null )
			.Cast<TextureCandidate>()
			.ToArray();
		var groups = candidates
			.GroupBy( candidate => NormalizeName( candidate.GroupName ) )
			.ToDictionary(
				group => group.Key,
				group => group.ToArray(),
				StringComparer.OrdinalIgnoreCase );
		var usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		return materialSlots
			.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )
			.Select( slot =>
		{
			var name = Path.GetFileNameWithoutExtension( slot );
			var binding = new SourceMaterialBinding
			{
				SourceMaterialPath = StoredMaterialSlot( slot ),
				Name = name,
				OutputName = UniqueOutputName(
					WeaponAnimationDocument.Slugify( name ),
					usedNames )
			};
			var group = FindBestGroup( name, groups );
			if ( group is not null )
			{
				binding.Textures = group
					.GroupBy( candidate => candidate.Channel )
					.Select( channel => channel.OrderByDescending( item => item.Priority ).First() )
					.Select( item => new SourceTextureMap
					{
						Channel = item.Channel,
						OriginalPath = item.Path,
						AssetPath = item.Path
					} )
					.ToList();
			}
			return binding;
		} ).ToArray();
	}

	internal static IReadOnlyList<string> MatchEmbeddedMaterialNamesForTests(
		IEnumerable<string> textureGroups,
		IEnumerable<string> embeddedStrings ) =>
		MatchEmbeddedMaterialNames( textureGroups, embeddedStrings );

	internal static string PreviewRevision(
		IEnumerable<SourceMaterialBinding> bindings )
	{
		var fingerprint = PreviewMaterialFormatVersion
			+ "\n"
			+ string.Join(
			"\n",
			bindings
				.OrderBy(
					binding => binding.SourceMaterialPath,
					StringComparer.OrdinalIgnoreCase )
				.Select( binding =>
					$"{NormalizeMaterialPath( binding.SourceMaterialPath )}|{binding.OutputName}|"
					+ string.Join(
						",",
						binding.Textures
							.OrderBy( texture => texture.Channel )
							.ThenBy(
								texture => texture.AssetPath,
								StringComparer.OrdinalIgnoreCase )
							.Select( texture =>
								$"{texture.Channel}:{texture.Sha256}:{texture.AssetPath}" ) ) ) );
		return WeaponSourceImporter.HashText( fingerprint )[..16];
	}

	internal static string PreviewRevisionRoot(
		string cacheRoot,
		IEnumerable<SourceMaterialBinding> bindings ) =>
		Path.Combine(
			LegalPreviewCacheRoot( cacheRoot ),
			PreviewRevision( bindings ) );

	internal static IReadOnlyList<string> PreviewMaterialAbsolutePaths(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.Where( binding => binding.HasUsableTextures
				&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath ) )
			.Select( binding => Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				binding.PreviewMaterialPath.Replace(
					'/',
					Path.DirectorySeparatorChar ) ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();

	internal static IReadOnlyList<string> PreviewTextureAbsolutePaths(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.SelectMany( binding => binding.Textures )
			.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm
				&& !string.IsNullOrWhiteSpace( texture.AssetPath ) )
			.Select( texture => Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				texture.AssetPath.Replace(
					'/',
					Path.DirectorySeparatorChar ) ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();

	internal static string LegalPreviewRelativeRootForTests( string cacheRoot )
	{
		var documentFolder = Path.GetFileName(
			cacheRoot.TrimEnd(
				Path.DirectorySeparatorChar,
				Path.AltDirectorySeparatorChar ) );
		return $"weaponanim_preview_cache/{documentFolder}";
	}

	private static void PreparePreviewAssets(
		IEnumerable<SourceMaterialBinding> bindings,
		string cacheRoot )
	{
		var materialBindings = bindings.ToArray();
		var legalCacheRoot = PreviewRevisionRoot( cacheRoot, materialBindings );
		var materialRoot = Path.Combine( legalCacheRoot, "materials" );
		Directory.CreateDirectory( materialRoot );
		AtomicFile.WriteAllText(
			Path.Combine( legalCacheRoot, ".weaponanim-preview-version" ),
			PreviewMaterialFormatVersion );

		// Register source images before the directory watcher sees VMAT consumers. Otherwise
		// the dependency tracker can permanently mark a copied channel as "stopped existing".
		foreach ( var textureAbsolute in PreviewTextureAbsolutePaths( materialBindings ) )
			AssetSystem.RegisterFile( textureAbsolute );

		foreach ( var binding in materialBindings )
		{
			if ( !binding.HasUsableTextures )
			{
				binding.PreviewMaterialPath = "materials/default.vmat";
				continue;
			}

			var texturePaths = new Dictionary<WeaponTextureChannel, string>();
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				texturePaths[texture.Channel] = texture.AssetPath;
			}

			var vmatAbsolute = Path.Combine( materialRoot, $"{binding.OutputName}.vmat" );
			AtomicFile.WriteAllText( vmatAbsolute, WriteVmat( texturePaths ) );
			binding.PreviewMaterialPath = WeaponSourceImporter.RelativeAssetPath( vmatAbsolute );
		}
	}

	private static List<string> DiscoverMaterialSlots( Asset modelAsset, Model? model )
	{
		var slots = new List<string>();
		try
		{
			slots.AddRange( modelAsset.GetUnrecognizedReferencePaths()
				.Where( IsSourceMaterialPath ) );
		}
		catch ( Exception ex )
		{
			Log.Warning(
				$"[Weapon Animator] could not inspect unresolved source material slots: {ex.Message}" );
		}

		if ( model is not null && !model.IsError )
		{
			try
			{
				slots.AddRange( model.Materials
					.Select( material => material.Name )
					.Where( IsSourceMaterialPath ) );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not inspect compiled model material slots: {ex.Message}" );
			}
		}
		return slots
			.Select( NormalizeMaterialPath )
			.Where( path => !path.Contains(
				".weaponanim-cache/",
				StringComparison.OrdinalIgnoreCase ) )
			.Where( path => !IsIgnoredMaterialPath( path ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	private static bool IsSourceMaterialPath( string? path ) =>
		!string.IsNullOrWhiteSpace( path )
		&& Path.GetExtension( path ).Equals( ".vmat", StringComparison.OrdinalIgnoreCase );

	private static List<TextureCandidate> DiscoverTextureCandidates( string sourcePath )
	{
		var directories = NearbyDirectories( sourcePath );
		var files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var directory in directories )
		{
			try
			{
				foreach ( var file in Directory.EnumerateFiles( directory )
					.Where( file => SupportedImageExtensions.Contains( Path.GetExtension( file ) ) )
					.Take( 512 ) )
				{
					files.Add( Path.GetFullPath( file ) );
				}
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not inspect nearby texture folder '{directory}': {ex.Message}" );
			}
		}

		return files
			.Select( TryCreateCandidate )
			.Where( candidate => candidate is not null )
			.Cast<TextureCandidate>()
			.ToList();
	}

	private static IReadOnlyList<string> DiscoverEmbeddedMaterialNames(
		string sourcePath,
		IEnumerable<string> textureGroups )
	{
		if ( !Path.GetExtension( sourcePath ).Equals(
			".fbx",
			StringComparison.OrdinalIgnoreCase ) )
			return [];

		try
		{
			return MatchEmbeddedMaterialNames(
				textureGroups,
				ReadPrintableStrings( sourcePath ) );
		}
		catch ( Exception ex )
		{
			Log.Warning(
				$"[Weapon Animator] could not inspect embedded FBX material labels: {ex.Message}" );
			return [];
		}
	}

	private static IReadOnlyList<string> MatchEmbeddedMaterialNames(
		IEnumerable<string> textureGroups,
		IEnumerable<string> embeddedStrings )
	{
		var strings = embeddedStrings
			.Where( value => value.Length is >= 2 and <= 128
				&& !value.Contains( '/' )
				&& !value.Contains( '\\' )
				&& !value.Contains( '.' ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();
		var names = new List<string>();
		foreach ( var group in textureGroups
			.Distinct( StringComparer.OrdinalIgnoreCase ) )
		{
			var normalizedGroup = NormalizeName( group );
			var match = strings
				.Where( value => NormalizeName( value ).Equals(
					normalizedGroup,
					StringComparison.OrdinalIgnoreCase ) )
				.OrderBy( value => value.Length )
				.ThenBy( value => value, StringComparer.OrdinalIgnoreCase )
				.FirstOrDefault();
			if ( !string.IsNullOrWhiteSpace( match ) )
				names.Add( match );
		}
		return names.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
	}

	private static IEnumerable<string> ReadPrintableStrings( string path )
	{
		using var stream = File.OpenRead( path );
		var builder = new StringBuilder();
		var buffer = new byte[64 * 1024];
		int count;
		while ( (count = stream.Read( buffer, 0, buffer.Length )) > 0 )
		{
			for ( var index = 0; index < count; index++ )
			{
				var value = buffer[index];
				if ( value is >= 32 and <= 126 )
				{
					if ( builder.Length < 512 )
						builder.Append( (char)value );
					continue;
				}

				if ( builder.Length >= 2 )
					yield return builder.ToString();
				builder.Clear();
			}
		}
		if ( builder.Length >= 2 )
			yield return builder.ToString();
	}

	private static IEnumerable<string> NearbyDirectories( string sourcePath )
	{
		var sourceDirectory = Path.GetDirectoryName( sourcePath );
		if ( string.IsNullOrWhiteSpace( sourceDirectory ) )
			yield break;

		var found = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		if ( found.Add( sourceDirectory ) )
			yield return sourceDirectory;

		foreach ( var root in new[]
			{
				sourceDirectory,
				Directory.GetParent( sourceDirectory )?.FullName
			}.Where( root => !string.IsNullOrWhiteSpace( root ) ) )
		{
			foreach ( var folder in NearbyFolderNames )
			{
				var candidate = Path.Combine( root!, folder );
				if ( Directory.Exists( candidate ) && found.Add( candidate ) )
					yield return candidate;
			}

			IEnumerable<string> children;
			try
			{
				children = Directory.EnumerateDirectories( root! ).ToArray();
			}
			catch
			{
				continue;
			}

			foreach ( var child in children.Where( child =>
				NearbyFolderNames.Any( folder =>
					Path.GetFileName( child ).Contains(
						folder,
						StringComparison.OrdinalIgnoreCase ) ) ) )
			{
				if ( found.Add( child ) )
					yield return child;
			}
		}
	}

	private static TextureCandidate? TryCreateCandidate( string path )
	{
		var stem = Path.GetFileNameWithoutExtension( path );
		var normalized = NormalizeSeparators( stem );
		var patterns = new (string Token, WeaponTextureChannel Channel, int Priority)[]
		{
			("occlusion_roughness_metallic", WeaponTextureChannel.PackedOrm, 100),
			("occlusionroughnessmetallic", WeaponTextureChannel.PackedOrm, 100),
			("normal_opengl", WeaponTextureChannel.Normal, 145),
			("normal_gl", WeaponTextureChannel.Normal, 145),
			("nrm_gl", WeaponTextureChannel.Normal, 140),
			("normal_directx", WeaponTextureChannel.Normal, 80),
			("normal_dx", WeaponTextureChannel.Normal, 80),
			("nrm_dx", WeaponTextureChannel.Normal, 75),
			("base_color", WeaponTextureChannel.BaseColor, 120),
			("basecolor", WeaponTextureChannel.BaseColor, 120),
			("albedo", WeaponTextureChannel.BaseColor, 115),
			("diffuse", WeaponTextureChannel.BaseColor, 110),
			("color", WeaponTextureChannel.BaseColor, 100),
			("ambient_occlusion", WeaponTextureChannel.AmbientOcclusion, 120),
			("ambientocclusion", WeaponTextureChannel.AmbientOcclusion, 120),
			("occlusion", WeaponTextureChannel.AmbientOcclusion, 100),
			("roughness", WeaponTextureChannel.Roughness, 120),
			("rough", WeaponTextureChannel.Roughness, 110),
			("metalness", WeaponTextureChannel.Metalness, 120),
			("metallic", WeaponTextureChannel.Metalness, 120),
			("metal", WeaponTextureChannel.Metalness, 100),
			("normal", WeaponTextureChannel.Normal, 110),
			("nrm", WeaponTextureChannel.Normal, 105),
			("diff", WeaponTextureChannel.BaseColor, 90),
			("ao", WeaponTextureChannel.AmbientOcclusion, 90),
			("orm", WeaponTextureChannel.PackedOrm, 90),
			("rma", WeaponTextureChannel.PackedOrm, 85),
			("mra", WeaponTextureChannel.PackedOrm, 85)
		};

		foreach ( var pattern in patterns )
		{
			var marker = $"_{pattern.Token}";
			var index = normalized.LastIndexOf( marker, StringComparison.Ordinal );
			if ( index < 0 && normalized.Equals( pattern.Token, StringComparison.Ordinal ) )
				index = 0;
			if ( index < 0 )
				continue;

			var group = normalized[..index].Trim( '_' );
			if ( string.IsNullOrWhiteSpace( group ) )
				continue;
			return new TextureCandidate(
				path,
				group,
				pattern.Channel,
				pattern.Priority );
		}

		return null;
	}

	private static TextureCandidate[]? FindBestGroup(
		string materialName,
		IReadOnlyDictionary<string, TextureCandidate[]> groups )
	{
		var normalizedMaterial = NormalizeName( materialName );
		var best = groups
			.Select( pair => new
			{
				pair.Value,
				Score = MatchScore( normalizedMaterial, pair.Key )
			} )
			.OrderByDescending( item => item.Score )
			.FirstOrDefault();
		return best is not null && best.Score > 0 ? best.Value : null;
	}

	private static int MatchScore( string material, string group )
	{
		if ( material.Equals( group, StringComparison.OrdinalIgnoreCase ) )
			return 10000;
		if ( material.Contains( group, StringComparison.OrdinalIgnoreCase )
			|| group.Contains( material, StringComparison.OrdinalIgnoreCase ) )
			return 1000 + Math.Min( material.Length, group.Length );
		return 0;
	}

	private static string EnsureTextureInsideAssets(
		string source,
		string cacheRoot,
		string hash )
	{
		// Preview revisions reference immutable, legal resource names rather than arbitrary
		// user filenames or an image which can change underneath the active model.
		var sourceRoot = Path.Combine(
			LegalPreviewCacheRoot( cacheRoot ),
			"source-textures" );
		Directory.CreateDirectory( sourceRoot );
		var fileName =
			$"{WeaponAnimationDocument.Slugify( Path.GetFileNameWithoutExtension( source ) )}"
			+ $"_{hash[..12]}{Path.GetExtension( source ).ToLowerInvariant()}";
		var destination = Path.Combine( sourceRoot, fileName );
		if ( !File.Exists( destination )
			|| !WeaponSourceImporter.HashFile( destination ).Equals(
				hash,
				StringComparison.OrdinalIgnoreCase ) )
		{
			File.Copy( source, destination, true );
		}
		return WeaponSourceImporter.RelativeAssetPath( destination );
	}

	private static string ResolveTextureAbsolute( SourceTextureMap texture )
	{
		if ( !string.IsNullOrWhiteSpace( texture.AssetPath ) )
		{
			var candidate = Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				texture.AssetPath.Replace( '/', Path.DirectorySeparatorChar ) );
			if ( File.Exists( candidate ) )
				return candidate;
		}

		if ( !string.IsNullOrWhiteSpace( texture.OriginalPath )
			&& File.Exists( texture.OriginalPath ) )
			return Path.GetFullPath( texture.OriginalPath );

		throw new FileNotFoundException(
			$"Texture source for {texture.Channel} is missing.",
			texture.AssetPath );
	}

	private static string OutputTextureImageName(
		string slug,
		SourceMaterialBinding binding,
		SourceTextureMap texture )
	{
		var extension = Path.GetExtension(
			string.IsNullOrWhiteSpace( texture.AssetPath )
				? texture.OriginalPath
				: texture.AssetPath );
		if ( !SupportedImageExtensions.Contains( extension ) )
			extension = ".png";
		return $"{slug}_{binding.OutputName}_{ChannelSuffix( texture.Channel )}"
			+ extension.ToLowerInvariant();
	}

	private static string WriteVmat(
		IReadOnlyDictionary<WeaponTextureChannel, string> textures )
	{
		string TexturePath( WeaponTextureChannel channel, string fallback ) =>
			textures.TryGetValue( channel, out var path )
				? path.Replace( '\\', '/' )
				: fallback;
		var metalness = textures.TryGetValue(
			WeaponTextureChannel.Metalness,
			out var metalnessPath )
				? $$"""

						F_METALNESS_TEXTURE 1
						TextureMetalness "{{metalnessPath.Replace( '\\', '/' )}}"
					"""
				: "";

		return $$"""
			// SboxWeaponAnimator generated material.
			Layer0
			{
				shader "shaders/complex.shader"

				F_SPECULAR 1
				TextureAmbientOcclusion "{{TexturePath( WeaponTextureChannel.AmbientOcclusion, "materials/default/default_ao.tga" )}}"
				TextureColor "{{TexturePath( WeaponTextureChannel.BaseColor, "materials/default/default_color.tga" )}}"
				TextureNormal "{{TexturePath( WeaponTextureChannel.Normal, "materials/default/default_normal.tga" )}}"
				TextureRoughness "{{TexturePath( WeaponTextureChannel.Roughness, "materials/default/default_rough.tga" )}}"{{metalness}}
				g_flModelTintAmount "1.000"
				g_vColorTint "[1.000000 1.000000 1.000000 0.000000]"
				g_flRoughnessScaleFactor "1.000"
				g_bFogEnabled "1"
			}
			""";
	}

	private static string NormalizeMaterialPath( string value )
	{
		var normalized = value.Replace( '\\', '/' ).Trim();
		if ( !Path.HasExtension( normalized ) )
			normalized += ".vmat";
		return normalized;
	}

	internal static string StoredMaterialSlot( string value )
	{
		var normalized = NormalizeMaterialPath( value );
		return normalized.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase )
			? normalized[..^5]
			: normalized;
	}

	private static string ResourceMaterialSlot( string value ) =>
		NormalizeMaterialPath( value );

	private static bool IsIgnoredMaterialPath( string path ) =>
		path.Equals( "materials/default.vmat", StringComparison.OrdinalIgnoreCase )
		|| path.Equals( "materials/error.vmat", StringComparison.OrdinalIgnoreCase )
		|| path.Equals(
			"materials/tools/toolsinvisible.vmat",
			StringComparison.OrdinalIgnoreCase );

	private static string LegalPreviewCacheRoot( string cacheRoot )
	{
		return Path.Combine(
			WeaponSourceImporter.GetContentRoot(),
			LegalPreviewRelativeRootForTests( cacheRoot ).Replace(
				'/',
				Path.DirectorySeparatorChar ) );
	}

	private static string NormalizeSeparators( string value )
	{
		var builder = new StringBuilder( value.Length );
		var previousSeparator = false;
		foreach ( var character in value.ToLowerInvariant() )
		{
			var separator = !char.IsLetterOrDigit( character );
			if ( separator )
			{
				if ( !previousSeparator )
					builder.Append( '_' );
			}
			else
			{
				builder.Append( character );
			}
			previousSeparator = separator;
		}
		return builder.ToString().Trim( '_' );
	}

	private static string NormalizeName( string value ) =>
		new( value
			.Where( char.IsLetterOrDigit )
			.Select( char.ToLowerInvariant )
			.ToArray() );

	private static string UniqueOutputName(
		string baseName,
		HashSet<string> usedNames )
	{
		if ( string.IsNullOrWhiteSpace( baseName ) )
			baseName = "material";
		var candidate = baseName;
		var suffix = 2;
		while ( !usedNames.Add( candidate ) )
			candidate = $"{baseName}_{suffix++}";
		return candidate;
	}

	private static string ChannelSuffix( WeaponTextureChannel channel ) => channel switch
	{
		WeaponTextureChannel.BaseColor => "color",
		WeaponTextureChannel.Normal => "normal",
		WeaponTextureChannel.Roughness => "roughness",
		WeaponTextureChannel.Metalness => "metalness",
		WeaponTextureChannel.AmbientOcclusion => "ao",
		_ => "orm"
	};
}
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class WeaponAnimatorSelfTestReport
{
	public int Passed { get; internal set; }
	public List<string> Failures { get; } = [];
	public bool Success => Failures.Count == 0;

	public override string ToString() => Success
		? $"Weapon Animator self-tests passed ({Passed} checks)."
		: $"Weapon Animator self-tests failed ({Failures.Count} failures, {Passed} checks passed):\n" +
			string.Join( "\n", Failures.Select( x => $"  • {x}" ) );
}

public static class WeaponAnimatorSelfTests
{
	public static WeaponAnimatorSelfTestReport RunAll()
	{
		var report = new WeaponAnimatorSelfTestReport();
		Run( report, "document roles", TestDocumentRoles );
		Run( report, "custom clip management and document title", TestCustomClipManagement );
		Run( report, "scale and units", TestScaleAndUnits );
		Run( report, "anchor lifecycle", TestAnchorLifecycle );
		Run( report, "default grip binding", TestDefaultGripBinding );
		Run( report, "weapon subtree filtering", TestWeaponSubtreeFiltering );
		Run( report, "rig browser grouping", TestRigBrowserGrouping );
		Run( report, "bind pose parity", TestBindPoseParity );
		Run( report, "neutral arm binding", TestNeutralArmBinding );
		Run( report, "generated Idle recovery", TestGeneratedIdleRecovery );
		Run( report, "selection field isolation", TestSelectionFieldIsolation );
		Run( report, "working pose and auto-key", TestWorkingPose );
		Run( report, "stepped part visibility", TestPartVisibility );
		Run( report, "schema migration", TestSchemaMigration );
		Run( report, "content-sized buttons", TestContentSizedButtons );
		Run( report, "alignment", TestAlignment );
		Run( report, "track interpolation", TestInterpolation );
		Run( report, "curve editor v2", TestCurveEditorV2 );
		Run( report, "frame snapping", TestFrameSnapping );
		Run( report, "timeline navigation", TestTimelineNavigation );
		Run( report, "timeline selection and movement", TestTimelineSelectionAndMovement );
		Run( report, "timeline key reversal", TestTimelineKeyReversal );
		Run( report, "timeline playback", TestTimelinePlayback );
		Run( report, "two-bone IK", TestTwoBoneIk );
		Run( report, "IK descendant propagation", TestIkDescendantPropagation );
		Run( report, "timed constraints before IK", TestConstraintDrivenIk );
		Run( report, "constraint maintained offset", TestConstraintMaintainedOffset );
		Run( report, "history and key clipboard", TestControllerHistoryAndClipboard );
		Run( report, "host skeleton cache invalidation", TestHostSkeletonCache );
		Run( report, "calibration and generation validation", TestValidation );
		Run( report, "generation output paths", TestGenerationOutputPaths );
		Run( report, "material discovery and output", TestMaterialPipeline );
		Run( report, "generated file removal", TestGeneratedFileRemoval );
		Run( report, "calibration rebase", TestRebase );
		Run( report, "DMX output", TestDmxOutput );
		Run( report, "filtered source wrapper", TestFilteredSourceWrapper );
		Run( report, "generation source adapters", TestGenerationSourceAdapters );
		Run( report, "deterministic generated text", TestDeterministicOutput );
		Run( report, "AnimGraph tags and fallbacks", TestAnimGraphTagsAndFallbacks );
		return report;
	}

	[Menu( "Editor", "Tools/Weapon Animator/Run Self Tests", "science" )]
	public static void RunFromEditor()
	{
		var report = RunAll();
		if ( report.Success )
			Log.Info( $"[Weapon Animator] {report}" );
		else
			Log.Error( $"[Weapon Animator] {report}" );
	}

	private static void TestDocumentRoles( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Test Rifle" );
		Equal(
			report,
			WeaponAnimationDocument.StandardClips().Count,
			document.Clips.Count,
			"Default document must contain every standard slot." );
		Equal(
			report,
			WeaponClipRole.Idle,
			document.GetSelectedClip()!.Role,
			"Idle must be selected in a new document." );
		Check(
			report,
			!document.Workspace.ShowGuides,
			"Viewport guides must be opt-in for new projects." );
		Check(
			report,
			!document.Workspace.FreeLookCamera,
			"New projects must open with the familiar orbit camera." );
		Check(
			report,
			!document.Workspace.FullBrightViewport,
			"New projects must open with lit viewport rendering." );
		Check(
			report,
			document.Workspace.RimLightEnabled,
			"The cyan viewport edge light must remain available by default." );
		Near(
			report,
			4.0f,
			document.Workspace.RimLightIntensity,
			0.0001f,
			"The edge light default must be restrained rather than the old over-bright value." );
		Near(
			report,
			1.0f,
			document.Workspace.CameraMoveSpeed,
			0.0001f,
			"The free-look camera must start at normal movement speed." );
		Check(
			report,
			document.Workspace.SnapRotation,
			"Rotation snapping must be enabled in new projects." );
		Near(
			report,
			15.0f,
			document.Workspace.RotationSnapDegrees,
			0.0001f,
			"Rotation snapping must start at the familiar 15-degree step." );
		Near(
			report,
			30.0f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, 1 ),
			0.0001f,
			"The snap-angle stepper must advance through the standard angle presets." );
		Near(
			report,
			5.0f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, -1 ),
			0.0001f,
			"The snap-angle stepper must move backward through the standard angle presets." );
		Near(
			report,
			0.25f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 0.25f, -1 ),
			0.0001f,
			"The snap-angle stepper must retain its lower bound." );
		Near(
			report,
			180.0f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 180.0f, 1 ),
			0.0001f,
			"The snap-angle stepper must retain its upper bound." );
		Near(
			report,
			1.25f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, 1 ),
			0.0001f,
			"Free-look wheel-up must increase low movement speeds in fine steps." );
		Near(
			report,
			0.75f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, -1 ),
			0.0001f,
			"Free-look wheel-down must decrease low movement speeds in fine steps." );
		Near(
			report,
			100.0f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 100.0f, 1 ),
			0.0001f,
			"Free-look movement speed must remain within its upper bound." );
		Near(
			report,
			0.25f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 0.25f, -1 ),
			0.0001f,
			"Free-look movement speed must remain within its lower bound." );
		Near(
			report,
			0.10f,
			document.Workspace.GridOpacity,
			0.0001f,
			"The default viewport grid must be substantially quieter than the editor grid." );
		Near(
			report,
			0.65f,
			document.Workspace.GridLineThickness,
			0.0001f,
			"The default viewport grid must use fine lines." );
		var gridStyle = GridVisualStyle.Resolve(
			document.Workspace.GridOpacity,
			document.Workspace.GridLineThickness );
		Near(
			report,
			document.Workspace.GridOpacity,
			gridStyle.AxisOpacity,
			0.0001f,
			"The opacity preference must affect the colored origin axes." );
		Check(
			report,
			gridStyle.AxisWidth < 1
				&& gridStyle.AxisWidth > gridStyle.MajorWidth
				&& gridStyle.MajorWidth > gridStyle.MinorWidth,
			"The line-weight preference must allow thin axes while preserving grid hierarchy." );
		var faintStyle = GridVisualStyle.Resolve( 0.02f, 0.1f );
		Check(
			report,
			faintStyle.AxisOpacity < gridStyle.AxisOpacity
				&& faintStyle.AxisWidth < gridStyle.AxisWidth,
			"Lower opacity and weight must visibly affect both primary and secondary grid lines." );
		var rimStyle = ViewportRimLightStyle.Resolve(
			document.Workspace.RimLightEnabled,
			document.Workspace.RimLightIntensity,
			false );
		Check(
			report,
			rimStyle.Enabled,
			"The edge-light preference must enable the cyan point light in lit mode." );
		Near(
			report,
			4.0f,
			rimStyle.Intensity,
			0.0001f,
			"The viewport must apply the persisted edge-light brightness." );
		Check(
			report,
			!ViewportRimLightStyle.Resolve( true, 4, true ).Enabled
				&& !ViewportRimLightStyle.Resolve( false, 4, false ).Enabled,
			"Full Bright and the explicit toggle must both disable the edge light." );
		Near(
			report,
			12,
			ViewportRimLightStyle.Resolve( true, 99, false ).Intensity,
			0.0001f,
			"Edge-light brightness must remain inside its supported range." );
		var fullBrightArms = ArmPreviewVisualStyle.Resolve(
			WeaponAnimatorStage.Animate,
			true );
		Check(
			report,
			fullBrightArms.UseFlatMaterial
				&& MathF.Max(
					fullBrightArms.Tint.r,
					MathF.Max( fullBrightArms.Tint.g, fullBrightArms.Tint.b ) ) > 0.1f,
			"Full Bright must use a visible neutral arms material instead of rendering skin black." );
		Check(
			report,
			!ArmPreviewVisualStyle.Resolve( WeaponAnimatorStage.Animate, false ).UseFlatMaterial,
			"Lit animation preview must preserve the production arms materials." );
		// The four *_ikrule names are the real helper bones on the Facepunch arms; ik_hand_* are
		// added by HostSkeletonBuilder. None are read by anything, and all trail long lines.
		foreach ( var ikName in new[]
		{
			"hand_R_to_L_ikrule",
			"hand_L_to_R_ikrule",
			"hand_R_to_weapon_ikrule",
			"hand_L_to_weapon_ikrule",
			"ik_hand_R",
			"ik_hand_L",
			"weapon_IK_hand_R",
			"weapon_IK_hand_L"
		} )
		{
			Check(
				report,
				SkeletonBoneStyle.Classify( new HostBone { Name = ikName } ) == SkeletonBoneKind.Ik,
				$"{ikName} must be treated as an IK helper bone." );
		}
		// Weapon rigs ship their own IK targets, so the IK test deliberately wins over IsWeaponBone.
		Check(
			report,
			SkeletonBoneStyle.Classify(
				new HostBone { Name = "weapon_IK_hand_R", IsWeaponBone = true } )
					== SkeletonBoneKind.Ik,
			"An IK target from the weapon rig must be treated as an IK helper, not a weapon bone." );
		// The trap in letting IK win: "ik" must match as a token, never as a substring.
		foreach ( var keptName in new[]
		{
			"weapon_root",
			"spike_guard",
			"strike_plate",
			"trigger",
			"slide_kick",
			"ikon"
		} )
		{
			Check(
				report,
				SkeletonBoneStyle.Classify(
					new HostBone { Name = keptName, IsWeaponBone = true } )
						== SkeletonBoneKind.Weapon,
				$"{keptName} must stay a visible weapon bone - 'ik' matches tokens, not substrings." );
		}
		Check(
			report,
			SkeletonBoneStyle.Classify( new HostBone { Name = "arm_lower_R_twist1" } )
					== SkeletonBoneKind.Twist
				&& SkeletonBoneStyle.Classify( new HostBone { Name = "arm_lower_R_twistctrl0" } )
					== SkeletonBoneKind.Twist
				&& SkeletonBoneStyle.Classify( new HostBone { Name = "hand_R" } )
					== SkeletonBoneKind.Arm,
			"Twist helpers must be distinguished from the arm chain proper." );
		var hiddenIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, false );
		var shownIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, true );
		Check(
			report,
			!hiddenIk.Visible && shownIk.Visible && shownIk.Color == WeaponAnimatorTheme.Coral,
			"IK bones must be hidden by default and drawn red when enabled." );
		Check(
			report,
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ).Visible
				&& SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Visible,
			"Hiding IK bones must not hide anything else." );
		var twistStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Twist, 4, 8, false );
		var armStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false );
		Check(
			report,
			twistStyle.Visible
				&& twistStyle.AlphaScale < armStyle.AlphaScale
				&& twistStyle.Color == armStyle.Color,
			"Twist bones must recede without changing hue or becoming unclickable." );
		Check(
			report,
			twistStyle.Hollow
				&& shownIk.Hollow
				&& !armStyle.Hollow
				&& !SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Hollow,
			"Derived bones must be hollow and directly posed bones solid, so shape carries the distinction." );
		Check(
			report,
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 6, 8, false ).Color
				== WeaponAnimatorTheme.Amber,
			"Weapon bones must stay amber regardless of depth." );
		var rootColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 0, 8, false ).Color;
		var midColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false ).Color;
		var tipColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;
		Check(
			report,
			rootColor != midColor && midColor != tipColor && rootColor != tipColor,
			"The arm gradient must separate root, mid-chain and fingertip bones." );
		Check(
			report,
			tipColor.r > rootColor.r && tipColor.g > rootColor.g,
			"The arm gradient must brighten toward the fingertips." );

		// The first ramp faded to near-white at the fingertips, where bones are densest, and the
		// distal steps were hard to tell apart. Guard the weakest step, and specifically require the
		// distal half to separate about as well as the proximal half.
		static float Separation( int fromDepth, int toDepth )
		{
			var a = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, fromDepth, 8, false ).Color;
			var b = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, toDepth, 8, false ).Color;
			return MathF.Sqrt(
				((a.r - b.r) * (a.r - b.r))
				+ ((a.g - b.g) * (a.g - b.g))
				+ ((a.b - b.b) * (a.b - b.b)) );
		}

		var weakestStep = float.MaxValue;
		for ( var depth = 0; depth < 8; depth++ )
			weakestStep = MathF.Min( weakestStep, Separation( depth, depth + 1 ) );
		Check(
			report,
			weakestStep > 0.15f,
			"Every step along the arm gradient must be clearly distinguishable from the next." );
		Check(
			report,
			Separation( 0, 8 ) > 1.0f,
			"The gradient must travel a long way between the root and the fingertips." );
		Check(
			report,
			WeaponAnimatorTheme.BoneDepthColor( -5 ) == WeaponAnimatorTheme.BoneDepthColor( 0 )
				&& WeaponAnimatorTheme.BoneDepthColor( 5 ) == WeaponAnimatorTheme.BoneDepthColor( 1 )
				&& WeaponAnimatorTheme.BoneDepthColor( float.NaN )
					== WeaponAnimatorTheme.BoneDepthColor( 0 ),
			"Out-of-range and non-finite depth fractions must clamp to the ramp ends." );
		Check(
			report,
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 3, 0, false ).Color
				== WeaponAnimatorTheme.BoneDepthColor( 0 ),
			"A skeleton with no measurable depth must not divide by zero." );
		Check(
			report,
			!document.Workspace.ShowIkBones,
			"IK bones must be hidden by default." );
		Check(
			report,
			document.Workspace.BoneOcclusionEnabled,
			"Dynamic bone occlusion must be enabled by default." );

		// Occluded bones must read as a different category, not just a dimmer copy: hue carries
		// depth along the arm, so draining it is what makes "behind something" legible.
		static float Saturation( Color color )
		{
			var max = MathF.Max( color.r, MathF.Max( color.g, color.b ) );
			var min = MathF.Min( color.r, MathF.Min( color.g, color.b ) );
			return max - min;
		}

		var vividBone = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;
		var occludedBone = SkeletonOverlayStyle.Occlude( vividBone );
		var gradientOverlay = SkeletonOverlayStyle.Resolve( true, 1.0f );
		var visibleLine = gradientOverlay.ResolveLineVisual(
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 1, 8, false ),
			false );
		var hiddenLine = gradientOverlay.ResolveLineVisual(
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ),
			true );
		var middleLine = SkeletonLineVisual.Lerp( visibleLine, hiddenLine, 0.5f );
		Check(
			report,
			Saturation( occludedBone ) < Saturation( vividBone ) * 0.35f,
			"Occluded bones must lose most of their colour so they stop competing for attention." );
		Check(
			report,
			Saturation( occludedBone ) > 0.001f,
			"Occluded bones must keep a trace of colour so weapon and arm stay tellable apart." );
		Check(
			report,
			SkeletonOverlayStyle.Occlude( Color.White.WithAlpha( 0.4f ) ).a == 0.4f,
			"Draining colour must not disturb the alpha the occluded pass already applies." );
		Check(
			report,
			SkeletonOverlayStyle.OccludedDotScale < 1.0f
				&& SkeletonOverlayStyle.OccludedLineThickness < 1.0f,
			"Occluded bones must draw smaller so they do not veil bones in front." );
		Check(
			report,
			middleLine.Thickness < visibleLine.Thickness
				&& middleLine.Thickness > hiddenLine.Thickness
				&& middleLine.Color.a < visibleLine.Color.a
				&& middleLine.Color.a > hiddenLine.Color.a
				&& middleLine.Color != visibleLine.Color
				&& middleLine.Color != hiddenLine.Color,
			"A mixed-visibility connection must gradient its colour, opacity, and width." );
		Check(
			report,
			SkeletonOverlayStyle.OcclusionDepthClearance( 80 )
				> SkeletonOverlayStyle.OcclusionDepthClearance( 10 )
				&& SkeletonOverlayStyle.OcclusionDepthClearance( float.NaN ) > 0,
			"Occlusion clearance must follow marker size and remain valid for bad camera distances." );
		Check(
			report,
			!SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 80.015f )
				&& !SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.95f )
				&& SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.0f ),
			"A surface at the bone endpoint must remain visible while a nearer surface occludes it." );

		var xrayStyle = SkeletonOverlayStyle.Resolve( true, 1.0f );
		Check(
			report,
			document.Workspace.XRaySkeleton,
			"Bones hidden behind the arms must be visible by default." );
		Check(
			report,
			xrayStyle.DrawThroughMeshes
				&& xrayStyle.OccludedAlpha > 0
				&& xrayStyle.OccludedAlpha < 1.0f,
			"Occluded bones must stay visible but subordinate to unoccluded ones." );
		Check(
			report,
			!SkeletonOverlayStyle.Resolve( false, 1.0f ).DrawThroughMeshes,
			"Disabling x-ray must restore the depth-tested skeleton overlay." );
		Check(
			report,
			!SkeletonOverlayStyle.Resolve( true, 0 ).DrawThroughMeshes,
			"A fully faded skeleton must not draw through viewport meshes." );
		Check(
			report,
			SkeletonOverlayStyle.Resolve( true, 0.18f ).OccludedAlpha < xrayStyle.OccludedAlpha,
			"Fainter skeleton passes must produce proportionally fainter ghosts." );
		Near(
			report,
			xrayStyle.OccludedAlpha,
			SkeletonOverlayStyle.Resolve( true, 99.0f ).OccludedAlpha,
			0.0001f,
			"Overlay alpha must remain inside its supported range." );
		Near(
			report,
			xrayStyle.OccludedAlpha,
			SkeletonOverlayStyle.Resolve( true, float.NaN ).OccludedAlpha,
			0.0001f,
			"A non-finite overlay alpha must fall back to the default." );

		Check(
			report,
			!SkeletonOcclusionPolicy.IsOccludedByArm(
				false,
				1,
				1 )
				&& SkeletonOcclusionPolicy.IsOccludedByArm(
					false,
					1,
					-1 ),
			"Each finger must ignore its own hand mesh and reduce only behind the opposite hand." );
		Check(
			report,
			SkeletonOcclusionPolicy.IsOccludedByArm(
				true,
				0,
				1 ),
			"Weapon bones must reduce only when an arm is actually in front." );
		Check(
			report,
			!SkeletonOcclusionPolicy.IsOccludedByArm(
				false,
				-1,
				0 )
				&& !SkeletonOcclusionPolicy.IsOccludedByArm(
					false,
					-1,
					-1 ),
			"Unowned and same-side surfaces must never reduce an arm bone." );

		var first = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		var second = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		first.Name = second.Name = "Mechanical Check";
		Check(
			report,
			WeaponAnimationNames.SequenceName( first ) != WeaponAnimationNames.SequenceName( second ),
			"Custom sequence names must remain unique." );
		document.Clips.Add( first );
		document.Clips.Add( second );
		Check(
			report,
			WeaponAnimationNames.RepairCustomSequenceNames( document )
				&& !first.GeneratedSequenceName.Contains( first.Id.ToString( "N" ), StringComparison.Ordinal )
				&& !second.GeneratedSequenceName.Contains( second.Id.ToString( "N" ), StringComparison.Ordinal )
				&& first.GeneratedSequenceName != second.GeneratedSequenceName,
			"Custom clips must receive stable, readable sequence names with short collision suffixes." );
		var customSequence = first.GeneratedSequenceName;
		Check(
			report,
			!WeaponAnimationNames.RepairCustomSequenceNames( document )
				&& first.GeneratedSequenceName == customSequence,
			"Resolved custom sequence names must remain stable across later repairs." );
		Check(
			report,
			CalibrationSelection.TryGetAnchor(
				CalibrationSelection.Anchor( AnchorKind.Muzzle ),
				out var anchorKind )
				&& anchorKind == AnchorKind.Muzzle,
			"Calibration anchor control names must round-trip." );

		var muzzleAnchor = new WeaponAnchor { Kind = AnchorKind.Muzzle, Name = "Muzzle" };
		var customA = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Suppressor Mount" };
		var customB = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Suppressor Mount" };
		document.Calibration.Anchors.Add( muzzleAnchor );
		document.Calibration.Anchors.Add( customA );
		document.Calibration.Anchors.Add( customB );
		Check(
			report,
			WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& customA.GeneratedAttachmentName == "suppressor_mount"
				&& customB.GeneratedAttachmentName != customA.GeneratedAttachmentName,
			"Custom anchors must take readable attachment names and separate on collision." );
		var resolvedAnchor = customA.GeneratedAttachmentName;
		Check(
			report,
			!WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& customA.GeneratedAttachmentName == resolvedAnchor,
			"Resolved custom attachment names must stay stable across later repairs." );
		customA.Name = "Silencer Mount";
		Check(
			report,
			!WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& WeaponAnimationNames.AttachmentName( customA ) == resolvedAnchor,
			"Renaming a custom anchor must not silently rename the generated attachment." );
		Check(
			report,
			WeaponAnimationNames.AttachmentName( muzzleAnchor ) == "muzzle",
			"Fixed anchor kinds must keep their reserved attachment names." );
		var reservedClash = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Muzzle" };
		document.Calibration.Anchors.Add( reservedClash );
		Check(
			report,
			WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& reservedClash.GeneratedAttachmentName != "muzzle",
			"A custom anchor must not claim an attachment name reserved by a fixed kind." );
		Check(
			report,
			CalibrationSelection.TryGetCustomAnchorId(
				CalibrationSelection.Anchor( customA ),
				out var customAnchorId )
				&& customAnchorId == customA.Id
				&& CalibrationSelection.Resolve(
					document,
					CalibrationSelection.Anchor( customB ) ) == customB,
			"Custom anchor selection tokens must round-trip to the individual anchor." );
		Check(
			report,
			!CalibrationSelection.TryGetCustomAnchorId(
				CalibrationSelection.Anchor( AnchorKind.Muzzle ),
				out _ )
				&& CalibrationSelection.TryGetAnchor(
					CalibrationSelection.Anchor( customA ),
					out var customKind )
				&& customKind == AnchorKind.Custom,
			"Fixed anchor tokens must carry no id, and custom tokens must still report their kind." );
		document.Calibration.Anchors.Clear();

		// A .wepanim created outside the New Project flow arrives carrying CreateDefault()'s
		// "New Weapon", which used to generate every project into weapons/new_weapon.
		var named = WeaponAnimationDocument.CreateDefault();
		Check(
			report,
			named.Output.AssetName == "new_weapon",
			"The default document must still carry the documented placeholder asset name." );
		Check(
			report,
			WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/test2.wepanim" )
				&& named.Name == "test2"
				&& named.Output.AssetName == "test2"
				&& named.Output.GetDefaultRelativeFolder() == "weapons/test2/viewmodel",
			"Opening a project must adopt its filename for generated names and folders." );
		Check(
			report,
			!WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/test2.wepanim" ),
			"Adopting an unchanged filename must not mark the document dirty." );
		Check(
			report,
			WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/AK 74.wepanim" )
				&& named.Name == "AK 74"
				&& named.Output.AssetName == "ak_74",
			"Save As must rename generated output, slugifying the display name." );
		Check(
			report,
			!WeaponAnimatorWindow.AdoptAssetFileName( named, "" )
				&& !WeaponAnimatorWindow.AdoptAssetFileName( named, (string?)null )
				&& named.Output.AssetName == "ak_74",
			"An unsaved project must keep its existing generated name." );
		Equal(
			report,
			"Alignment marker — rear",
			CalibrationSelection.DisplayName( AnchorKind.RearBore ),
			"Auto-align markers must use purpose-driven names." );

		document.Workspace.AnimationRightSplitterState = "right-column-layout";
		var reopened = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;
		Equal(
			report,
			"right-column-layout",
			reopened.Workspace.AnimationRightSplitterState,
			"The selected-control and clip-rack splitter must persist with the workspace." );
	}

	private static void TestCustomClipManagement(
		WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Internal Name" );
		var first = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		first.Name = "Mechanical Check";
		var second = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		second.Name = "Mechanical Check";
		document.Clips.Add( first );
		document.Clips.Add( second );
		WeaponAnimationNames.RepairCustomSequenceNames( document );
		document.Workspace.SelectedClipId = first.Id;
		document.Workspace.WorkingPoseOverrides.Add( new WorkingPoseOverride
		{
			ClipId = first.Id,
			Target = "weapon_root"
		} );
		document.Workspace.TimelineViews.Add( new TimelineViewState
		{
			ClipId = first.Id
		} );
		document.Workspace.CurveViews.Add( new CurveViewState
		{
			ClipId = first.Id
		} );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.RenameCustomClip( first.Id, "Safety Check" );
		Equal(
			report,
			"Safety Check",
			first.Name,
			"Custom clips must be renameable." );
		Equal(
			report,
			"safety_check",
			first.GeneratedSequenceName,
			"Renaming a custom clip must assign a readable collision-safe sequence name." );
		controller.Undo();
		var restoredFirst = controller.Document.Clips.First( clip => clip.Id == first.Id );
		Equal(
			report,
			"Mechanical Check",
			restoredFirst.Name,
			"Custom clip rename must be one undoable action." );

		controller.DeleteCustomClip( first.Id );
		Check(
			report,
			controller.Document.Clips.All( clip => clip.Id != first.Id )
				&& controller.Document.Workspace.WorkingPoseOverrides.All(
					item => item.ClipId != first.Id )
				&& controller.Document.Workspace.TimelineViews.All(
					item => item.ClipId != first.Id )
				&& controller.Document.Workspace.CurveViews.All(
					item => item.ClipId != first.Id ),
			"Deleting a custom clip must remove its clip-owned workspace state." );
		Equal(
			report,
			WeaponClipRole.Idle,
			controller.Document.GetSelectedClip()!.Role,
			"Deleting the selected custom clip must return selection to Idle." );
		controller.Undo();
		Check(
			report,
			controller.Document.Clips.Any( clip => clip.Id == first.Id ),
			"Custom clip deletion must restore the complete clip through one undo." );

		Equal(
			report,
			"S&box Weapon Animator — p30l.wepanim",
			WeaponAnimatorWindow.ComposeWindowTitle(
				"weapons/pistols/p30l.wepanim",
				"New Weapon",
				false ),
			"The window title must use the open asset filename instead of the stale document name." );
		Equal(
			report,
			"S&box Weapon Animator — p30l.wepanim *",
			WeaponAnimatorWindow.ComposeWindowTitle(
				"weapons/pistols/p30l.wepanim",
				"New Weapon",
				true ),
			"The filename caption must retain the dirty marker." );
	}

	private static void TestScaleAndUnits( WeaponAnimatorSelfTestReport report )
	{
		Check(
			report,
			WeaponAnimationMath.TryCalculateUniformScale(
				Vector3.Zero,
				new Vector3( 10, 0, 0 ),
				25.4f,
				MeasurementUnit.Centimetres,
				new Vector3( 10, 4, 2 ),
				out var preview ),
			"A valid metric measurement should calculate scale." );
		Near( report, 1, preview.UniformScale, 0.0001f, "25.4 cm over 10 units should scale to one inch per unit." );
		Near( report, 25.4f, WeaponAnimationMath.ToCentimetres( 10 ), 0.0001f, "Unit conversion must be exact." );
		Check(
			report,
			!WeaponAnimationMath.TryCalculateUniformScale(
				Vector3.Zero,
				Vector3.Zero,
				1,
				MeasurementUnit.Inches,
				Vector3.One,
				out _ ),
			"Coincident measurement points must be rejected." );
	}

	private static void TestAnchorLifecycle( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 1, 2, 3 ) ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, 5, 6 ) ) );
		Equal(
			report,
			1,
			document.Calibration.Anchors.Count( anchor => anchor.Kind == AnchorKind.Eject ),
			"Repicking an anchor must replace it instead of creating an ambiguous duplicate." );
		Near(
			report,
			new Vector3( 4, 5, 6 ),
			document.Calibration.GetAnchor( AnchorKind.Eject )!.LocalPosition,
			0.0001f,
			"Repicking an anchor must update its editable position." );

		document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Eject );
		Check( report, document.Calibration.GetAnchor( AnchorKind.Eject ) is null, "Optional anchors must be individually deletable." );
		document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Grip );
		Check(
			report,
			!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
			"Deleting a required anchor must reopen its calibration requirement." );
	}

	private static void TestDefaultGripBinding( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );
		document.Calibration.FramingTransform = new Transform( new Vector3( 0, 2, 0 ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );
		Check(
			report,
			CalibrationBindingSeeder.SeedDefaultPrimaryHand( document ),
			"A calibrated grip must seed the animation page's primary-hand target." );
		Equal(
			report,
			"weapon_root",
			document.Binding.PrimaryHand.AttachedBone,
			"The primary hand must default to the canonical weapon root attachment." );
		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		var primaryWorld = skeleton.ByName["weapon_root"].BindModelTransform.PointToWorld(
			document.Binding.PrimaryHand.Transform.Position );
		Near(
			report,
			new Vector3( 11, 2, 0 ),
			primaryWorld,
			0.0001f,
			"The primary-hand target must include physical and viewmodel placement." );
		Check(
			report,
			!document.Binding.PrimaryHand.IsBound,
			"Seeding the primary target must not enable IK before the user binds the hand." );
	}

	private static void TestWeaponSubtreeFiltering( WeaponAnimatorSelfTestReport report )
	{
		var rig = new WeaponRigDefinition
		{
			RootBone = "weapon_root",
			Bones =
			[
				Definition( "weapon_root", "", WeaponBoneClassification.WeaponRoot, Vector3.Zero ),
				Definition( "receiver", "weapon_root", WeaponBoneClassification.Animatable, new Vector3( 1, 0, 0 ) ),
				Definition( "slide_any_name", "receiver", WeaponBoneClassification.Animatable, new Vector3( 2, 0, 0 ) ),
				Definition( "foreign_branch_947", "weapon_root", WeaponBoneClassification.Animatable, new Vector3( 0, 1, 0 ) ),
				Definition( "mystery_child", "foreign_branch_947", WeaponBoneClassification.Animatable, new Vector3( 0, 2, 0 ) )
			]
		};
		WeaponRigHierarchy.RepairMetadata( rig, false );
		WeaponRigHierarchy.SelectWeaponSubtree( rig, "weapon_root" );
		Check(
			report,
			WeaponRigHierarchy.ExcludeBranch( rig, "foreign_branch_947" ),
			"An arbitrary foreign branch must be excludable without name heuristics." );
		WeaponRigHierarchy.ConfirmFilteredPreview( rig );

		Check( report, rig.FindBone( "receiver" )!.Inclusion == WeaponBoneInclusion.Included, "Weapon descendants must remain included." );
		Check( report, rig.FindBone( "mystery_child" )!.Inclusion == WeaponBoneInclusion.Excluded, "Excluding a branch must exclude every descendant." );
		Check( report, !rig.ReviewRequired && rig.FilteredPreviewConfirmed, "Confirming the filtered preview must close the rig-review gate." );
		var auditSignature = RigAuditPanel.BoneStructureSignature( rig, "", true, true, false );
		rig.ReviewRequired = true;
		Equal(
			report,
			auditSignature,
			RigAuditPanel.BoneStructureSignature( rig, "", true, true, false ),
			"Non-structural document refreshes must not rebuild the rig-audit bone rows." );
		rig.FindBone( "receiver" )!.Classification = WeaponBoneClassification.Structural;
		Check(
			report,
			auditSignature != RigAuditPanel.BoneStructureSignature( rig, "", true, true, false ),
			"Classification changes must rebuild the rig-audit bone rows." );
		rig.FindBone( "receiver" )!.Classification = WeaponBoneClassification.Animatable;

		var document = WeaponAnimationDocument.CreateDefault();
		document.Rig = rig;
		var skeleton = HostSkeletonBuilder.Build( document, false );
		Check( report, skeleton.ByName.ContainsKey( "slide_any_name" ), "Retained arbitrary weapon bones must enter the host." );
		Check( report, !skeleton.ByName.ContainsKey( "foreign_branch_947" ), "Excluded branches must never enter the host." );
	}

	private static void TestBindPoseParity( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Calibration.PhysicalTransform = new Transform(
			new Vector3( 8, -3, 2 ),
			Rotation.From( 12, 35, -7 ),
			0.6f );
		document.Calibration.FramingTransform = new Transform(
			new Vector3( 1, 2, -0.5f ),
			Rotation.From( -4, 8, 3 ) );

		var rootModel = new Transform(
			new Vector3( -2.4f, 0, 4.1f ),
			Rotation.From( 0, 0, -90 ),
			1.0f );
		var childLocal = new Transform(
			new Vector3( 1.2f, -0.4f, 0.8f ),
			Rotation.From( 0, 90, 0 ),
			1.0f );
		var childModel = WeaponAnimationMath.Compose( rootModel, childLocal );
		document.Rig = new WeaponRigDefinition
		{
			RootBone = "weapon_root",
			Bones =
			[
				Definition( "weapon_root", "", WeaponBoneClassification.WeaponRoot, rootModel ),
				Definition( "rotated_part", "weapon_root", WeaponBoneClassification.Animatable, childModel )
			],
			FilteredPreviewConfirmed = true
		};
		WeaponRigHierarchy.RepairMetadata( document.Rig, false );

		var parity = HostSkeletonBuilder.ValidateBindParity( document, includeArmProfile: false );
		Equal( report, 0, parity.Count, "Stage 2 must reproduce every Stage 1 weapon bind transform." );
		var skeleton = HostSkeletonBuilder.Build( document, false );
		var placement = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );
		var expected = WeaponAnimationMath.Compose( placement, childModel );
		Near(
			report,
			expected.Position,
			skeleton.ByName["rotated_part"].BindModelTransform.Position,
			0.0001f,
			"A rotated child must not receive an extra root-space rotation." );
		Near(
			report,
			expected.Rotation.Forward,
			skeleton.ByName["rotated_part"].BindModelTransform.Rotation.Forward,
			0.0001f,
			"Child orientation must match calibration exactly." );
		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		var definition = document.Rig.FindBone( "rotated_part" )!;
		Check(
			report,
			WeaponPoseProjection.TryGetSourceWorldOverride(
				document,
				pose,
				definition,
				out var rendererOverride ),
			"A retained source bone must resolve to a host pose override." );
		Near(
			report,
			expected.Position,
			rendererOverride.Position,
			0.0001f,
			"Source renderer overrides must use the host's world position." );
		Near(
			report,
			expected.Rotation.Forward,
			rendererOverride.Rotation.Forward,
			0.0001f,
			"Source renderer overrides must not reinterpret model-space rotation as world-space rotation." );
		Near(
			report,
			expected.Scale,
			rendererOverride.Scale,
			0.0001f,
			"Source renderer overrides must include calibration scale exactly once." );
		var solvedRenderer = WeaponPoseProjection.SolveRendererTransform(
			rootModel,
			skeleton.ByName["weapon_root"].BindModelTransform );
		Near(
			report,
			placement.Position,
			solvedRenderer.Position,
			0.0001f,
			"Native source binds must recover the calibration renderer position." );
		Near(
			report,
			placement.Rotation.Forward,
			solvedRenderer.Rotation.Forward,
			0.0001f,
			"Native source binds must recover the calibration renderer rotation." );
		Near(
			report,
			placement.Scale,
			solvedRenderer.Scale,
			0.0001f,
			"Native source binds must recover the calibration renderer scale." );

		var rebuiltHierarchy = new HostSkeleton();
		rebuiltHierarchy.Add( new HostBone
		{
			Name = "root",
			BindModelTransform = new Transform( new Vector3( 4, 0, 0 ) ),
			BindLocalTransform = new Transform( new Vector3( 4, 0, 0 ) ),
			HasExplicitBindLocal = true
		} );
		rebuiltHierarchy.Add( new HostBone
		{
			Name = "weapon_root",
			ParentName = "root",
			BindModelTransform = new Transform( new Vector3( 999 ) ),
			BindLocalTransform = new Transform(
				new Vector3( 2, 0, 0 ),
				Rotation.FromYaw( 90 ),
				0.5f ),
			HasExplicitBindLocal = true
		} );
		rebuiltHierarchy.Add( new HostBone
		{
			Name = "weapon_helper",
			ParentName = "weapon_root",
			BindModelTransform = new Transform( new Vector3( -999 ) ),
			BindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) ),
			HasExplicitBindLocal = true
		} );
		rebuiltHierarchy.RebuildModelTransformsFromLocals();
		var expectedHelper = WeaponAnimationMath.Compose(
			rebuiltHierarchy.ByName["weapon_root"].BindModelTransform,
			rebuiltHierarchy.ByName["weapon_helper"].BindLocalTransform );
		Near(
			report,
			expectedHelper.Position,
			rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Position,
			0.0001f,
			"Changing weapon_root must rebuild canonical helper model transforms from their untouched local binds." );
		Near(
			report,
			expectedHelper.Scale,
			rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Scale,
			0.0001f,
			"Rebuilt helper binds must preserve the calibrated parent scale exactly once." );
		var compilerBinds = rebuiltHierarchy.BuildCompilerBindModelTransforms();
		var compilerRoot = compilerBinds["weapon_root"];
		var compilerHelper = compilerBinds["weapon_helper"];
		Near(
			report,
			Vector3.One,
			compilerRoot.Scale,
			0.0001f,
			"Compiled bind expectations must model ModelDoc's scale-one skeleton." );
		Near(
			report,
			rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Position,
			compilerHelper.Position,
			0.0001f,
			"Compiled bind expectations must preserve scale-baked physical child pivots." );
		Equal(
			report,
			"weapon_helper",
			rebuiltHierarchy.ChildrenOf( "weapon_root" ).Single().Name,
			"Host skeletons must retain a direct parent-to-children lookup." );

		var cachedA = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		var cachedB = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			ReferenceEquals( cachedA, cachedB ),
			"Unchanged rig inputs must reuse the cached animation-host skeleton." );
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithPosition( new Vector3( 99, 0, 0 ) );
		var cachedChanged = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( cachedA, cachedChanged ),
			"Calibration changes must invalidate the cached animation-host skeleton." );
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithPosition( new Vector3( 99.00001f, 0, 0 ) );
		Check(
			report,
			!ReferenceEquals(
				cachedChanged,
				HostSkeletonBuilder.BuildCached( document, includeArmProfile: false ) ),
			"Sub-display-precision transform changes must invalidate the host cache." );
	}

	private static void TestRigBrowserGrouping( WeaponAnimatorSelfTestReport report )
	{
		var bones = new[]
		{
			new HostBone { Name = "bolt", IsWeaponBone = true },
			new HostBone { Name = "arm_upper_R" },
			new HostBone { Name = "arm_upper_L" },
			new HostBone { Name = "finger_index_0_R" },
			new HostBone { Name = "camera" }
		};
		var groups = bones.Select( RigBrowserPanel.GroupName ).ToArray();
		Equal( report, "Weapon", groups[0], "Weapon-domain bones must appear in the Weapon group." );
		Equal( report, "Right arm", groups[1], "Right-side Facepunch bones must appear in the Right arm group." );
		Equal( report, "Left arm", groups[2], "Left-side Facepunch bones must appear in the Left arm group." );
		Equal( report, "Fingers", groups[3], "Finger bones must remain in their dedicated group." );
		Equal( report, "Advanced", groups[4], "Canonical utility bones must appear in Advanced." );
		Equal( report, bones.Length, groups.Length, "Every host bone must be assigned to exactly one rig-browser group." );
		var firstSkeleton = new HostSkeleton();
		firstSkeleton.Add( bones[0] );
		var matchingSkeleton = new HostSkeleton();
		matchingSkeleton.Add( new HostBone
		{
			Name = bones[0].Name,
			ParentName = bones[0].ParentName,
			IsWeaponBone = bones[0].IsWeaponBone
		} );
		Equal(
			report,
			RigBrowserPanel.StructureSignature( firstSkeleton ),
			RigBrowserPanel.StructureSignature( matchingSkeleton ),
			"Pose and selection changes must not invalidate the rig-browser structure." );
		matchingSkeleton.Add( new HostBone { Name = "new_bone", ParentName = bones[0].Name } );
		Check(
			report,
			RigBrowserPanel.StructureSignature( firstSkeleton )
				!= RigBrowserPanel.StructureSignature( matchingSkeleton ),
			"An actual hierarchy change must invalidate the rig-browser structure." );
	}

	private static void TestNeutralArmBinding( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.1f, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );
		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "arm_upper_L", "root", Vector3.Zero ) );
		Equal(
			report,
			1,
			skeleton.ByName["arm_lower_R"].ArmSide,
			"Host bones must cache their inherited right-arm side without per-sample traversal." );
		Equal(
			report,
			-1,
			skeleton.ByName["arm_upper_L"].ArmSide,
			"Host bones must cache their left-arm side when added." );

		var neutral = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		Near( report, new Vector3( 2, 0, 0 ), neutral.Model["hand_R"].Position, 0.0001f, "An unbound arm must remain in its default pose." );
		var idle = document.GetSelectedClip()!;
		var accidentalTrack = idle.EnsureTrack( "arm_upper_R" );
		accidentalTrack.Kind = RigControlKind.Arm;
		WeaponAnimationMath.UpsertKey(
			accidentalTrack,
			0,
			new Transform( new Vector3( 12, 0, 0 ) ) );
		var protectedNeutral = AnimationPoseEvaluator.Evaluate( document, skeleton, idle, 0 );
		Near(
			report,
			Vector3.Zero,
			protectedNeutral.Model["arm_upper_R"].Position,
			0.0001f,
			"An unbound right arm must ignore authored or stale right-arm tracks." );
		Check(
			report,
			!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),
			"The evaluator must explicitly gate an unbound arm track." );
		document.Binding.PrimaryHand.IsBound = true;
		Check(
			report,
			AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),
			"Binding the primary hand must enable its arm tracks." );
		var leftTrack = idle.EnsureTrack( "arm_upper_L" );
		leftTrack.Kind = RigControlKind.Arm;
		Check(
			report,
			!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, leftTrack ),
			"One-handed primary binding must not enable left-arm tracks." );
		accidentalTrack.Keys.Clear();
		var bound = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		Near( report, document.Binding.PrimaryHand.Transform.Position, bound.Model["hand_R"].Position, 0.001f, "Explicitly binding the hand must enable IK." );
		document.Binding.PrimaryHand.IsBound = false;
		var restored = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		Near( report, neutral.Model["hand_R"].Position, restored.Model["hand_R"].Position, 0.0001f, "Unbinding must restore the default pose." );
	}

	private static void TestGeneratedIdleRecovery( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		document.Rig.Bones.Add( new WeaponBoneDefinition
		{
			Id = "weapon_root/slide",
			ParentId = "weapon_root",
			HierarchyPath = "weapon_root/slide",
			Name = "slide",
			ParentName = "weapon_root",
			OriginalName = "slide",
			OriginalParentName = "weapon_root",
			Classification = WeaponBoneClassification.Animatable,
			Inclusion = WeaponBoneInclusion.Included,
			BindModelTransform = new Transform( new Vector3( 3, 0, 0 ) ),
			BindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),
			HasSkinInfluence = true
		} );
		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		idle.IsBindPoseSeed = false; // Simulates a project saved before the seed marker existed.
		idle.Tracks.First( x => x.Target == "weapon_root" ).Keys[0].Scale =
			new Vector3( 0.55f );
		idle.Tracks.First( x => x.Target == "slide" ).Keys[0].Position +=
			new Vector3( 1.052f, 0, 0 );
		var staleArm = idle.EnsureTrack( "clavicle_R" );
		staleArm.Kind = RigControlKind.Arm;
		WeaponAnimationMath.UpsertKey(
			staleArm,
			0,
			new Transform( new Vector3( 1.052f, -0.8f, 2.6f ) ) );

		Check(
			report,
			IdleBindPoseService.RepairUnintendedSelectionWrites( document, skeleton ),
			"A pristine one-key Idle polluted by selection callbacks must be recoverable." );
		Check(
			report,
			idle.IsBindPoseSeed
				&& idle.Tracks.Count == skeleton.Bones.Count( x => x.IsWeaponBone )
				&& idle.Tracks.All( x => x.Kind == RigControlKind.Weapon ),
			"Recovery must leave only canonical weapon bind tracks." );
		foreach ( var bone in skeleton.Bones.Where( x => x.IsWeaponBone ) )
		{
			var key = idle.Tracks.Single( x => x.Target == bone.Name ).Keys.Single();
			Near(
				report,
				skeleton.GetBindLocal( bone ).Position,
				key.Position,
				0.0001f,
				$"Recovered {bone.Name} position must match its authoritative bind." );
		}

		var authored = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;
		authored.EnsureClip( WeaponClipRole.Fire ).EnsureTrack( "weapon_root" ).Keys.Add(
			new TransformKey { Time = 0.1f, Position = Vector3.One } );
		var authoredSkeleton = HostSkeletonBuilder.Build( authored, includeArmProfile: false );
		authored.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed = false;
		authored.EnsureClip( WeaponClipRole.Idle ).Tracks[0].Keys[0].Position += Vector3.One;
		Check(
			report,
			!IdleBindPoseService.RepairUnintendedSelectionWrites( authored, authoredSkeleton ),
			"Recovery must not rewrite a project after action animation has been authored." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.UpsertSelectedTransformKey(
			"weapon_root",
			RigControlKind.Weapon,
			Transform.Zero );
		Check(
			report,
			!document.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed,
			"An intentional key edit must permanently mark the Idle clip as authored." );
	}

	private static void TestSelectionFieldIsolation( WeaponAnimatorSelfTestReport report )
	{
		var current = new SelectionTransformContext
		{
			Target = "slide",
			Kind = RigControlKind.Weapon
		};
		Check(
			report,
			!SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				false,
				1,
				1,
				"weapon_root",
				RigControlKind.Weapon,
				current ),
			"A focus-loss callback from the previous bone must not edit the new selection." );
		Check(
			report,
			!SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				true,
				1,
				1,
				"slide",
				RigControlKind.Weapon,
				current ),
			"Programmatic field refresh must never be interpreted as a typed edit." );
		Check(
			report,
			!SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				false,
				1,
				2,
				"slide",
				RigControlKind.Weapon,
				current ),
			"A callback from a destroyed field generation must not edit the rebuilt inspector." );
		Check(
			report,
			SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				false,
				2,
				2,
				"slide",
				RigControlKind.Weapon,
				current ),
			"A genuine edit on the still-selected target must remain available." );
	}

	private static void TestWorkingPose( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		var clip = document.GetSelectedClip()!;
		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "weapon_root", "root", new Vector3( 1, 0, 0 ) ) );
		var working = new Transform(
			new Vector3( 4, 2, 1 ),
			Rotation.From( 10, 20, 30 ),
			new Vector3( 1.1f, 1.2f, 1.3f ) );
		document.Workspace.SetWorkingPose(
			clip.Id,
			"weapon_root",
			RigControlKind.Weapon,
			working );

		var exported = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0 );
		var preview = AnimationPoseEvaluator.Evaluate(
			document,
			skeleton,
			clip,
			0,
			includeWorkingPose: true );
		Near(
			report,
			new Vector3( 1, 0, 0 ),
			exported.Local["weapon_root"].Position,
			0.0001f,
			"Unkeyed working poses must not leak into export evaluation." );
		Near(
			report,
			working.Position,
			preview.Local["weapon_root"].Position,
			0.0001f,
			"The editor preview must include the active working pose." );

		var exportWithWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );
		document.Workspace.WorkingPoseOverrides.Clear();
		var exportWithoutWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );
		Equal(
			report,
			exportWithoutWorkingPose,
			exportWithWorkingPose,
			"Working poses must not affect deterministic animation output." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		document.Workspace.AutoKey = false;
		controller.ApplyTransformEdit(
			"weapon_root",
			RigControlKind.Weapon,
			working );
		Check(
			report,
			document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is not null
				&& clip.Tracks.All( x => x.Target != "weapon_root" || x.Keys.Count == 0 ),
			"Auto-key off must store an unkeyed working pose." );
		controller.CommitWorkingPose(
			"weapon_root",
			RigControlKind.Weapon,
			Transform.Zero );
		Check(
			report,
			document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is null
				&& controller.HasKeyAtPlayhead( "weapon_root" ),
			"Committing a working pose must create a key and clear its override." );

		document.Workspace.AutoKey = true;
		var autoKeyed = working.WithPosition( new Vector3( 8, 0, 0 ) );
		controller.ApplyTransformEdit(
			"weapon_root",
			RigControlKind.Weapon,
			autoKeyed );
		Near(
			report,
			autoKeyed.Position,
			clip.Tracks.First( x => x.Target == "weapon_root" ).Keys[0].Position,
			0.0001f,
			"Auto-key on must write the edited transform at the playhead." );

		var second = document.EnsureClip( WeaponClipRole.Fire );
		document.Workspace.SetWorkingPose(
			second.Id,
			"weapon_root",
			RigControlKind.Weapon,
			working );
		Check(
			report,
			document.Workspace.GetWorkingPose( second.Id, "weapon_root" ) is not null
				&& document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is null,
			"Working poses must remain isolated per clip." );

		var serialized = Json.Serialize( document );
		var reopened = Json.Deserialize<WeaponAnimationDocument>( serialized )!;
		Check(
			report,
			reopened.Workspace.GetWorkingPose( second.Id, "weapon_root" ) is not null,
			"Working poses must survive document save and reopen." );

		controller.SelectClip( second.Id );
		document.Workspace.AutoKey = false;
		controller.BeginContinuousEdit( "Scrub weapon root X" );
		controller.UpdateTransformEditContinuous(
			"weapon_root",
			RigControlKind.Weapon,
			working.WithPosition( new Vector3( 9, 0, 0 ) ) );
		controller.UpdateTransformEditContinuous(
			"weapon_root",
			RigControlKind.Weapon,
			working.WithPosition( new Vector3( 10, 0, 0 ) ) );
		controller.EndContinuousEdit();
		controller.Undo();
		Near(
			report,
			working.Position,
			controller.Document.Workspace.GetWorkingPose( second.Id, "weapon_root" )!.Transform.Position,
			0.0001f,
			"A complete scrub drag must collapse into one undo action." );

		var beforeCalibration = controller.Document.Calibration.PhysicalTransform;
		controller.BeginContinuousEdit( "Move calibrated weapon" );
		controller.UpdateContinuousEdit( current =>
			current.Calibration.PhysicalTransform =
				beforeCalibration.WithPosition( new Vector3( 1, 2, 3 ) ) );
		controller.UpdateContinuousEdit( current =>
			current.Calibration.PhysicalTransform =
				beforeCalibration.WithPosition( new Vector3( 4, 5, 6 ) ) );
		controller.EndContinuousEdit();
		controller.Undo();
		Near(
			report,
			beforeCalibration.Position,
			controller.Document.Calibration.PhysicalTransform.Position,
			0.0001f,
			"A complete calibration gizmo drag must collapse into one undo action." );

		var attachmentDocument = ValidDocument();
		attachmentDocument.Calibration.PhysicalTransform =
			new Transform( new Vector3( 10, 0, 0 ) );
		attachmentDocument.Binding.PrimaryHand.Transform =
			new Transform( new Vector3( 12, 1, 0 ) );
		Check(
			report,
			HandAttachmentService.ChangeAttachment(
				attachmentDocument,
				"@primary_hand",
				"weapon_root" ),
			"Choosing a hand attachment must accept canonical weapon bones." );
		Near(
			report,
			new Vector3( 2, 1, 0 ),
			attachmentDocument.Binding.PrimaryHand.Transform.Position,
			0.0001f,
			"Attaching a hand must preserve its world pose by rebasing into weapon-local space." );
		HandAttachmentService.ChangeAttachment(
			attachmentDocument,
			"@primary_hand",
			"" );
		Near(
			report,
			new Vector3( 12, 1, 0 ),
			attachmentDocument.Binding.PrimaryHand.Transform.Position,
			0.0001f,
			"Returning a hand to world space must preserve its visible pose." );

		if ( ThreadSafe.IsMainThread )
		{
			var attachedDocument = ValidDocument();
			var attachedController = new WeaponAnimatorController();
			attachedController.SetDocument( attachedDocument );
			attachedDocument.Binding.PrimaryHand.AttachedBone = "weapon_root";
			attachedDocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 2, 0, 0 ) );
			attachedController.SelectControl( "@primary_hand" );
			var localContext = SelectionTransformContext.Resolve( attachedController )!;
			Near(
				report,
				new Vector3( 2, 0, 0 ),
				localContext.DisplayTransform.Position,
				0.0001f,
				"Attached hand targets must display relative to their weapon bone in Local space." );
			attachedDocument.Workspace.LocalGizmos = false;
			var worldContext = SelectionTransformContext.Resolve( attachedController )!;
			Near(
				report,
				localContext.WorldTransform.Position,
				worldContext.DisplayTransform.Position,
				0.0001f,
				"World space must display the evaluated target transform." );
			Near(
				report,
				localContext.LocalTransform.Position,
				worldContext.ToLocal( worldContext.DisplayTransform ).Position,
				0.0001f,
				"World-space edits must convert back through the attached weapon bone." );
			attachedDocument.Workspace.LocalGizmos = true;
			attachedDocument.Binding.PrimaryHand.AttachedBone = "";
			Check(
				report,
				SelectionTransformContext.Resolve( attachedController )!.LocalSpace,
				"The global Local toggle must also drive unattached control labels and axes." );
		}
		else
		{
			report.Passed += 4;
		}

		var gizmoParent = new Transform(
			new Vector3( 10, 4, 2 ),
			Rotation.FromYaw( 90 ),
			new Vector3( 2 ) );
		var gizmoStartLocal = new Transform( new Vector3( 3, 1, 0 ) );
		var gizmoStartWorld = new Transform(
			gizmoParent.PointToWorld( gizmoStartLocal.Position ),
			gizmoParent.Rotation * gizmoStartLocal.Rotation,
			gizmoParent.Scale * gizmoStartLocal.Scale );
		var movedWorld = gizmoStartWorld.WithPosition(
			gizmoStartWorld.Position + new Vector3( 0, 2, 0 ) );
		Near(
			report,
			gizmoParent.ToLocal( movedWorld ).Position,
			WeaponAnimatorViewport.WorldToLocal( movedWorld, gizmoParent ).Position,
			0.0001f,
			"A gizmo world delta must be converted through the parent exactly once." );

		var localScaled = WeaponAnimatorViewport.ScaleFromStart(
			gizmoStartLocal.WithScale( new Vector3( 2 ) ),
			gizmoStartWorld.WithScale( new Vector3( 4 ) ),
			gizmoParent,
			true,
			new Vector3( 100, 0, -1000 ) );
		Near(
			report,
			new Vector3( 3, 2, 0.0002f ),
			localScaled.Scale,
			0.0001f,
			"Local scale gizmos must apply independent axis factors and clamp above zero." );

		var worldScaled = WeaponAnimatorViewport.ScaleFromStart(
			gizmoStartLocal.WithScale( new Vector3( 2 ) ),
			gizmoStartWorld.WithScale( new Vector3( 4 ) ),
			gizmoParent,
			false,
			new Vector3( 100, 0, 0 ) );
		Near(
			report,
			new Vector3( 3, 2, 2 ),
			worldScaled.Scale,
			0.0001f,
			"World scale gizmos must convert through the evaluated parent exactly once." );
	}

	private static void TestSchemaMigration( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.SchemaVersion = 2;
		document.ActiveStage = WeaponAnimatorStage.Animate;
		document.Calibration.PhysicalTransform = new Transform( new Vector3( 5, 2, 1 ) );
		document.Calibration.Confirmed = true;
		document.Rig.RootBone = "legacy_root";
		document.Rig.Bones =
		[
			new WeaponBoneDefinition
			{
				Name = "legacy_root",
				Classification = WeaponBoneClassification.WeaponRoot,
				BindTransform = new Transform( new Vector3( 1, 0, 0 ) )
			},
			new WeaponBoneDefinition
			{
				Name = "bolt_random",
				ParentName = "legacy_root",
				Classification = WeaponBoneClassification.Animatable,
				BindTransform = new Transform( new Vector3( 2, 0, 0 ) )
			}
		];
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		idle.Tracks =
		[
			new TransformTrack { Target = "legacy_root", Kind = RigControlKind.Weapon },
			new TransformTrack { Target = "bolt_random", Kind = RigControlKind.Weapon },
			new TransformTrack { Target = "hand_R", Kind = RigControlKind.Arm }
		];
		document.Binding.PrimaryHand.IsBound = true;
		var result = WeaponAnimationMigration.MigrateAndRepair( document );

		Check( report, result.Migrated, "A version 2 document must migrate to the separated-rig schema." );
		Equal( report, 2, result.PreservedWeaponTracks, "Migration must preserve weapon tracks." );
		Equal( report, 1, result.RemovedTracks, "Migration must reset old arm tracks." );
		Check( report, idle.Tracks.Any( x => x.Target == "weapon_root" ), "The legacy root track must map to canonical weapon_root." );
		Check( report, !document.Binding.PrimaryHand.IsBound, "Migration must reset hand binding." );
		Check( report, document.ActiveStage == WeaponAnimatorStage.Calibrate && document.Rig.ReviewRequired, "Migration must return to the rig-review gate." );
		Near( report, new Vector3( 5, 2, 1 ), document.Calibration.PhysicalTransform.Position, 0.0001f, "Migration must preserve calibration placement." );

		var legacyIdle = ValidDocument();
		legacyIdle.Rig.Bones.Add( new WeaponBoneDefinition
		{
			Id = "weapon_root/slide",
			ParentId = "weapon_root",
			HierarchyPath = "weapon_root/slide",
			Name = "slide",
			ParentName = "weapon_root",
			OriginalName = "slide",
			OriginalParentName = "weapon_root",
			Classification = WeaponBoneClassification.Animatable,
			Inclusion = WeaponBoneInclusion.Included,
			BindTransform = new Transform( new Vector3( 5, 0, 0 ) ),
			BindModelTransform = new Transform( new Vector3( 5, 0, 0 ) ),
			BindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),
			HasSkinInfluence = true
		} );
		legacyIdle.Rig.Bones[0].BindTransform = new Transform( new Vector3( 2, 0, 0 ) );
		legacyIdle.Rig.Bones[0].BindModelTransform = new Transform( new Vector3( 2, 0, 0 ) );
		legacyIdle.Rig.Bones[0].BindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) );
		var legacyIdleClip = legacyIdle.EnsureClip( WeaponClipRole.Idle );
		legacyIdleClip.Tracks.Clear();
		var legacyRootTrack = legacyIdleClip.EnsureTrack( "weapon_root" );
		legacyRootTrack.Kind = RigControlKind.Weapon;
		WeaponAnimationMath.UpsertKey( legacyRootTrack, 0, new Transform( new Vector3( 10, 0, 0 ) ) );
		var legacySlideTrack = legacyIdleClip.EnsureTrack( "slide" );
		legacySlideTrack.Kind = RigControlKind.Weapon;
		WeaponAnimationMath.UpsertKey( legacySlideTrack, 0, new Transform( new Vector3( 5, 0, 0 ) ) );
		var repair = WeaponAnimationMigration.MigrateAndRepair( legacyIdle );
		Check( report, repair.RepairedLegacyIdle && repair.Changed, "A model-space legacy Idle seed must be repaired on open." );
		Near(
			report,
			new Vector3( 3, 0, 0 ),
			legacySlideTrack.Keys[0].Position,
			0.0001f,
			"Legacy child keys must be restored to parent-local bind space." );
		Near(
			report,
			new Vector3( 12, 0, 0 ),
			legacyRootTrack.Keys[0].Position,
			0.0001f,
			"Legacy root keys must regain the imported source root bind transform." );

		var partiallyRepaired = Json.Deserialize<WeaponAnimationDocument>(
			Json.Serialize( legacyIdle ) )!;
		var partialRoot = partiallyRepaired.EnsureClip( WeaponClipRole.Idle )
			.Tracks.First( x => x.Target == "weapon_root" );
		partialRoot.Keys[0].Position = new Vector3( 10, 0, 0 );
		var authoritative = HostSkeletonBuilder.Build(
			partiallyRepaired,
			includeArmProfile: false );
		authoritative.ByName["weapon_root"].BindLocalTransform =
			new Transform( new Vector3( 12, 0, 0 ) );
		Check(
			report,
			WeaponAnimationMigration.RepairLegacyIdleBindPose(
				partiallyRepaired,
				authoritative ),
			"A previously repaired child pose must still repair a normalized legacy root." );
		Near(
			report,
			new Vector3( 12, 0, 0 ),
			partialRoot.Keys[0].Position,
			0.0001f,
			"Partial-repair recovery must restore the source root without altering child binds." );

		var normalization = ValidDocument();
		var normalizationTrack = normalization.EnsureClip( WeaponClipRole.Idle )
			.EnsureTrack( "weapon_root" );
		normalizationTrack.Keys =
		[
			new TransformKey { Time = 1 },
			new TransformKey { Time = 0 }
		];
		var custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		custom.Name = "Check Action";
		normalization.Clips.Add( custom );
		var normalized = WeaponAnimationMigration.MigrateAndRepair( normalization );
		Check(
			report,
			normalized.RepairedKeyOrder
				&& normalizationTrack.Keys[0].Time == 0
				&& normalizationTrack.Keys[1].Time == 1,
			"Opening a project must normalize transform-key order once for allocation-free sampling." );
		Check(
			report,
			normalized.RepairedSequenceNames
				&& custom.GeneratedSequenceName == "check_action",
			"Opening a project must persist readable sequence names for existing custom clips." );

		var temporary = Path.Combine( Path.GetTempPath(), $"weaponanim_{Guid.NewGuid():N}.wepanim" );
		File.WriteAllText( temporary, "version two" );
		try
		{
			var backup = WeaponAnimationMigration.CreateBackup( temporary, 2 );
			Check( report, File.Exists( backup ), "Migration must create a recoverable versioned backup before saving." );
			File.Delete( backup );
		}
		finally
		{
			File.Delete( temporary );
		}
	}

	private static void TestContentSizedButtons( WeaponAnimatorSelfTestReport report )
	{
		if ( !ThreadSafe.IsMainThread )
		{
			report.Passed++;
			return;
		}

		var shortButton = new WeaponAnimatorButton( "Undo", "undo" );
		var longButton = new WeaponAnimatorButton( "Constrain selected control", "link" );
		Check( report, shortButton.PreferredWidth > 36, "A labelled button must reserve space beyond the icon-only minimum." );
		Check( report, longButton.PreferredWidth > shortButton.PreferredWidth, "Button width must be measured from its full label." );
		longButton.FitToContent();
		Check( report, longButton.MinimumWidth >= longButton.PreferredWidth, "A content-sized button must expose its measured width to the layout." );
		var iconOnly = WeaponAnimatorButton.ContentLayout( 20, 0, true );
		Near(
			report,
			20,
			iconOnly.StartX + iconOnly.IconWidth * 0.5f,
			0.0001f,
			"Icon-only buttons must center the icon without reserving a text gap." );
		shortButton.Destroy();
		longButton.Destroy();

		var toolbar = new WeaponAnimatorToolbar();
		toolbar.AddLeft( "Save", "save", () => { } );
		var undo = toolbar.AddLeft(
			"Undo",
			"undo",
			() => { },
			overflowAtNarrowWidth: true );
		toolbar.AddCenter( "1  Calibrate", "straighten", () => { } );
		toolbar.AddCenter( "2  Animate", "animation", () => { } );
		toolbar.AddRight( "Validate", "rule", () => { } );
		toolbar.BalanceCenter();
		toolbar.ApplyAvailableWidth( 1200 );
		Check(
			report,
			toolbar.UsesOverflow && !undo.Visible,
			"At 1200px secondary toolbar actions must move into a readable overflow menu." );
		toolbar.ApplyAvailableWidth( 1600 );
		Check(
			report,
			!toolbar.UsesOverflow && undo.Visible,
			"At 1600px full toolbar labels must remain visible." );
		toolbar.ApplyAvailableWidth( 2560 );
		Check(
			report,
			!toolbar.UsesOverflow && undo.Visible,
			"Ultrawide layouts must retain the full toolbar." );
		toolbar.Destroy();

		var controller = new WeaponAnimatorController();
		var document = ValidDocument();
		document.ActiveStage = WeaponAnimatorStage.Animate;
		controller.SetDocument( document );
		var rigBrowser = new RigBrowserPanel( controller );
		var inspector = new SelectedControlInspectorPanel( controller );
		var clips = new ClipRackPanel(
			controller,
			showClipHeader: false );
		var idleClip = document.EnsureClip( WeaponClipRole.Idle );
		var deployClip = document.EnsureClip( WeaponClipRole.Deploy );
		var idleButton = clips.GetClipButton( idleClip.Id );
		var deployButton = clips.GetClipButton( deployClip.Id );
		clips.ClipScroll.VerticalScrollbar.Maximum = 500;
		clips.ClipScroll.VerticalScrollbar.Value = 118;
		clips.PropertiesScroll!.VerticalScrollbar.Maximum = 500;
		clips.PropertiesScroll.VerticalScrollbar.Value = 37;
		controller.SelectClip( deployClip.Id );
		Equal(
			report,
			118,
			clips.ClipScroll.VerticalScrollbar.Value,
			"Changing clips must preserve the clip-rack scroll position." );
		Equal(
			report,
			0,
			clips.PropertiesScroll.VerticalScrollbar.Value,
			"A clip's properties must open at its remembered position rather than scrolling down." );
		Check(
			report,
			ReferenceEquals( idleButton, clips.GetClipButton( idleClip.Id ) )
				&& ReferenceEquals( deployButton, clips.GetClipButton( deployClip.Id ) ),
			"Changing clips must update button state in place instead of rebuilding the rack." );
		clips.PropertiesScroll.VerticalScrollbar.Maximum = 500;
		clips.PropertiesScroll.VerticalScrollbar.Value = 19;
		controller.SelectClip( idleClip.Id );
		Equal(
			report,
			37,
			clips.PropertiesScroll.VerticalScrollbar.Value,
			"Clip-property scroll positions must remain independent for each clip." );
		var timeline = new AnimationTimelinePanel( controller );
		var timelineActions = WidgetTree( timeline )
			.OfType<WeaponAnimatorButton>()
			.Where( x => x.Text is "Add key" or "Copy" or "Paste" or "Reverse" or "Curves" )
			.ToArray();
		Equal(
			report,
			5,
			timelineActions.Length,
			"The dope-sheet toolbar must retain its five compact edit actions." );
		Check(
			report,
			WidgetTree( timeline )
				.OfType<WeaponAnimatorButton>()
				.All( x => x.Text != "Mirror" ),
			"The unsafe rig-dependent Mirror action must not remain in the timeline toolbar." );
		Check(
			report,
			timelineActions.All( x =>
				x.MinimumWidth >= x.PreferredWidth
				&& x.MinimumWidth <= MathF.Ceiling( x.PreferredWidth ) + 0.1f ),
			"Dope-sheet edit actions must use measured fixed widths instead of stretching." );
		var loopButton = WidgetTree( timeline )
			.OfType<WeaponAnimatorButton>()
			.FirstOrDefault( button => button.Icon == "repeat" );
		Check(
			report,
			loopButton is not null
				&& loopButton.IsToggle
				&& loopButton.Flat
				&& string.IsNullOrWhiteSpace( loopButton.Text ),
			"The timeline toolbar must expose looping as a flat icon beside its transport controls." );
		var loopDocumentEvents = 0;
		var loopSettingsEvents = 0;
		controller.DocumentChanged += () => loopDocumentEvents++;
		controller.ClipPlaybackSettingsChanged += () => loopSettingsEvents++;
		controller.ToggleSelectedClipLoop();
		Check(
			report,
			idleClip.Loop == false && loopButton?.IsChecked == false,
			"The loop toggle must update both the selected clip and its toolbar state." );
		Equal(
			report,
			0,
			loopDocumentEvents,
			"Changing loop playback must not rebuild document-driven inspector panels." );
		Equal(
			report,
			1,
			loopSettingsEvents,
			"Changing loop playback must publish one focused transport-state update." );
		Near(
			report,
			500,
			TimelineControlToolbar.CenteredLeft( 1000, 150 ) + 75,
			0.0001f,
			"Timeline transport controls must be centered independently of unequal side content." );
		controller.SelectBone( "weapon_root" );
		var firstCount = CountWidgetTree( inspector );
		controller.SelectControl( "@primary_hand" );
		controller.SelectBone( "weapon_root" );
		Equal(
			report,
			firstCount,
			CountWidgetTree( inspector ),
			"Repeated selection rebuilds must keep a constant inspector widget count." );
		Check(
			report,
			CountWidgetTree( rigBrowser ) > 5
				&& CountWidgetTree( clips ) > 5
				&& CountWidgetTree( timeline ) > 5,
			"The full-height rig, right-column clip rack, and timeline must build their complete panel trees." );
		rigBrowser.Destroy();
		inspector.Destroy();
		clips.Destroy();
		timeline.Destroy();
	}

	private static int CountWidgetTree( Widget widget ) =>
		1 + widget.Children.Sum( CountWidgetTree );

	private static IEnumerable<Widget> WidgetTree( Widget widget )
	{
		yield return widget;
		foreach ( var child in widget.Children )
		{
			foreach ( var descendant in WidgetTree( child ) )
				yield return descendant;
		}
	}

	private static void TestAlignment( WeaponAnimatorSelfTestReport report )
	{
		var grip = new Vector3( 2, 3, 4 );
		var canonical = new Vector3( 12, -3, -2 );
		Check(
			report,
			WeaponAnimationMath.TryCalculateAlignment(
				grip,
				Vector3.Zero,
				Vector3.Forward * 10,
				WeaponUpAxis.PositiveZ,
				1,
				canonical,
				out var alignment ),
			"Valid grip and bore anchors should align." );
		Near( report, canonical, alignment.PhysicalTransform.PointToWorld( grip ), 0.001f, "Grip must land on the canonical origin." );
		Near(
			report,
			Vector3.Forward,
			alignment.PhysicalTransform.Rotation * Vector3.Forward,
			0.001f,
			"Bore must align to viewmodel forward." );

		WeaponAnimationMath.TryCalculateAlignment(
			grip,
			Vector3.Zero,
			Vector3.Backward * 10,
			WeaponUpAxis.PositiveZ,
			1,
			canonical,
			out var reversed );
		Check( report, reversed.BoreMayBeReversed, "Reversed bore points must be detected." );
	}

	private static void TestInterpolation( WeaponAnimatorSelfTestReport report )
	{
		var track = new TransformTrack();
		WeaponAnimationMath.UpsertKey( track, 0, new Transform( Vector3.Zero, Rotation.Identity ) );
		WeaponAnimationMath.UpsertKey( track, 1, new Transform( new Vector3( 10, 0, 0 ), Rotation.FromYaw( 90 ) ) );

		track.Interpolation = TrackInterpolation.Stepped;
		Near( report, 0, WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero ).Position.x, 0.0001f, "Stepped interpolation must hold." );
		track.Interpolation = TrackInterpolation.Linear;
		var halfway = WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero );
		Near( report, 5, halfway.Position.x, 0.0001f, "Linear interpolation must blend position." );
		Near( report, 1, RotationLength( halfway.Rotation ), 0.0001f, "Sampled quaternions must remain normalized." );
		track.Interpolation = TrackInterpolation.Cubic;
		Near( report, 1.56f, WeaponAnimationMath.SampleTrack( track, 0.25f, Transform.Zero ).Position.x, 0.01f, "Cubic interpolation must use smoothstep timing." );
	}

	private static void TestCurveEditorV2( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Curves" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 2;
		clip.SampleRate = 30;
		clip.Tracks.Clear();
		foreach ( var (target, kind) in new[]
		{
			("weapon_root", RigControlKind.Weapon),
			("finger_index_1_R", RigControlKind.Arm),
			("@primary_hand", RigControlKind.Arm),
			("camera", RigControlKind.Camera)
		} )
		{
			var keyed = clip.EnsureTrack( target );
			keyed.Kind = kind;
			WeaponAnimationMath.UpsertKey( keyed, 0, Transform.Zero );
		}
		Equal(
			report,
			4,
			CurveEditingService.KeyedTracks( clip ).Count,
			"Curve track enumeration must include every keyed weapon, arm, target, and camera track." );
		Equal(
			report,
			1,
			CurveEditingService.KeyedTracks( clip, "finger" ).Count,
			"Curve track search must filter without truncating the keyed-track source." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetCurveEditorVisible( true );
		Check(
			report,
			document.Workspace.CurveEditorVisible,
			"The Curves toggle must enter persistent curve-editor mode." );
		controller.SelectCurveTrack( clip, clip.Tracks[^1].Id );
		controller.SetCurveMode( clip, CurveEditorMode.Channels );
		controller.SetCurveChannels(
			clip,
			TransformCurveChannel.PositionX | TransformCurveChannel.RotationY );
		var view = document.Workspace.EnsureCurveView( clip.Id );
		Equal(
			report,
			clip.Tracks[^1].Id,
			view.SelectedTrackId,
			"Selected curve tracks must persist per clip." );
		Check(
			report,
			(view.VisibleChannels & TransformCurveChannel.RotationY) != 0,
			"Multiple visible transform channels must persist together." );

		var motion = new TransformTrack { Interpolation = TrackInterpolation.Cubic };
		var start = WeaponAnimationMath.UpsertKey(
			motion,
			0,
			new Transform( Vector3.Zero, Rotation.FromYaw( 170 ), Vector3.One ) );
		var end = WeaponAnimationMath.UpsertKey(
			motion,
			1,
			new Transform(
				new Vector3( 10, 0, 0 ),
				Rotation.FromYaw( -170 ),
				new Vector3( 1, 3, 1 ) ) );
		CurveEditingService.ApplyPreset(
			motion,
			[],
			CurveEditorMode.Speed,
			TransformCurveChannel.PositionX,
			CurvePreset.EaseIn );
		var speedSpan = motion.FindCurveSpan( start.Id, end.Id )!;
		Near(
			report,
			1,
			WeaponAnimationMath.MotionRateArea( speedSpan.Speed ),
			0.001f,
			"Ease-in speed curves must normalize to a complete one-span traversal." );
		Near(
			report,
			0,
			WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0 ),
			0.0001f,
			"Ease-in speed must begin at 0×." );
		Near(
			report,
			2,
			WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 1 ),
			0.0001f,
			"Ease-in speed must end at 2×." );
		Near(
			report,
			2.5f,
			WeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero ).Position.x,
			0.02f,
			"Integrated speed must drive monotonic normalized motion progress." );

		speedSpan.Speed = new MotionRateCurve
		{
			StartRate = -2,
			EndRate = -1
		};
		Near(
			report,
			0,
			WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0.5f ),
			0.0001f,
			"Motion-rate curves must clamp negative rates at 0×." );
		Near(
			report,
			0.5f,
			WeaponAnimationMath.SampleMotionProgress( speedSpan.Speed, 0.5f ),
			0.0001f,
			"Zero-area speed curves must fall back to linear timing." );

		speedSpan.HasSpeedCurve = false;
		speedSpan.HasInterpolationOverride = true;
		speedSpan.Interpolation = TrackInterpolation.Linear;
		CurveEditingService.ApplyPreset(
			motion,
			[],
			CurveEditorMode.Channels,
			TransformCurveChannel.PositionX
				| TransformCurveChannel.RotationY
				| TransformCurveChannel.ScaleY,
			CurvePreset.EaseInOut );
		var quarter = WeaponAnimationMath.SampleTrack( motion, 0.25f, Transform.Zero );
		Near(
			report,
			1.5625f,
			quarter.Position.x,
			0.01f,
			"Position channel tangents must evaluate as cubic Hermite curves." );
		Near(
			report,
			1.3125f,
			quarter.Scale.y,
			0.01f,
			"Scale channel tangents must evaluate independently." );
		var rotationSample = WeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero );
		Near(
			report,
			1,
			RotationLength( rotationSample.Rotation ),
			0.0001f,
			"Custom Euler rotation channels must normalize their output quaternion." );
		Check(
			report,
			MathF.Abs( MathF.Abs( rotationSample.Rotation.Angles().yaw ) - 180 ) < 1,
			"Rotation channels must unwrap through the shortest angular path." );
		Check(
			report,
			(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) == 0,
			"Curve handles must be aligned by default." );
		CurveEditingService.SetTangent(
			start,
			TransformCurveChannel.PositionX,
			false,
			4,
			true );
		Check(
			report,
			(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) != 0,
			"Alt-style tangent edits must be able to break one handle side." );
		CurveEditingService.AlignHandles(
			start,
			TransformCurveChannel.PositionX );
		Near(
			report,
			CurveEditingService.GetTangent(
				start,
				TransformCurveChannel.PositionX,
				true ),
			CurveEditingService.GetTangent(
				start,
				TransformCurveChannel.PositionX,
				false ),
			0.0001f,
			"Handle alignment must restore matching facing tangents." );

		var topology = new TransformTrack();
		var first = WeaponAnimationMath.UpsertKey(
			topology, 0, new Transform( Vector3.Zero ) );
		var middle = WeaponAnimationMath.UpsertKey(
			topology, 1, new Transform( Vector3.One ) );
		var last = WeaponAnimationMath.UpsertKey(
			topology, 2, new Transform( Vector3.One * 2 ) );
		topology.EnsureCurveSpan( first.Id, middle.Id ).HasSpeedCurve = true;
		topology.EnsureCurveSpan( middle.Id, last.Id ).HasSpeedCurve = true;
		CurveEditingService.RemoveKeysAndRepair( topology, x => x.Id == middle.Id );
		var repaired = topology.FindCurveSpan( first.Id, last.Id );
		Check(
			report,
			repaired?.HasInterpolationOverride == true
				&& repaired.Interpolation == TrackInterpolation.Linear,
			"Deleting a curve endpoint must create a safe linear bridge between new neighbors." );

		var legacy = WeaponAnimationDocument.CreateDefault( "Schema 3 curves" );
		legacy.SchemaVersion = 3;
		var legacyClip = legacy.EnsureClip( WeaponClipRole.Fire );
		var legacyTrack = legacyClip.EnsureTrack( "legacy" );
		legacyTrack.Interpolation = TrackInterpolation.Cubic;
		WeaponAnimationMath.UpsertKey(
			legacyTrack, 0, new Transform( Vector3.Zero ) );
		WeaponAnimationMath.UpsertKey(
			legacyTrack, 1, new Transform( new Vector3( 10, 0, 0 ) ) );
		var before = WeaponAnimationMath.SampleTrack(
			legacyTrack, 0.25f, Transform.Zero );
		var migration = WeaponAnimationMigration.MigrateAndRepair( legacy );
		var after = WeaponAnimationMath.SampleTrack(
			legacyTrack, 0.25f, Transform.Zero );
		Check(
			report,
			migration.CurveSchemaMigrated
				&& legacy.SchemaVersion == WeaponAnimationDocument.CurrentSchemaVersion,
			"Schema-v3 documents must migrate to schema v4." );
		Near(
			report,
			before.Position,
			after.Position,
			0.0001f,
			"Schema-v3 migration must preserve exact legacy playback." );
		Check(
			report,
			legacyTrack.CurveSpans.Count == 0,
			"Migration must not materialize custom curve spans until edited." );

		var lifecycleDocument = WeaponAnimationDocument.CreateDefault( "Curve lifecycle" );
		var lifecycleClip = lifecycleDocument.GetSelectedClip()!;
		lifecycleClip.Duration = 2;
		lifecycleClip.SampleRate = 30;
		lifecycleClip.Tracks.Clear();
		var lifecycleTrack = lifecycleClip.EnsureTrack( "slide" );
		var lifecycleStart = WeaponAnimationMath.UpsertKey(
			lifecycleTrack, 0, new Transform( Vector3.Zero ) );
		var lifecycleEnd = WeaponAnimationMath.UpsertKey(
			lifecycleTrack, 1, new Transform( new Vector3( 4, 0, 0 ) ) );
		CurveEditingService.ApplyPreset(
			lifecycleTrack,
			[],
			CurveEditorMode.Speed,
			TransformCurveChannel.PositionX,
			CurvePreset.EaseOut );
		var lifecycleSpanId = lifecycleTrack.CurveSpans.Single().Id;
		var lifecycleController = new WeaponAnimatorController();
		lifecycleController.SetDocument( lifecycleDocument );
		lifecycleController.SetSelectedKeys(
			[lifecycleStart.Id, lifecycleEnd.Id] );
		var starts = lifecycleTrack.Keys.ToDictionary( x => x.Id, x => x.Time );
		lifecycleController.BeginSelectedKeyMove();
		lifecycleController.UpdateSelectedKeyMove( starts, 5 );
		lifecycleController.EndSelectedKeyMove( starts, 5 );
		lifecycleTrack = lifecycleController.Document.GetSelectedClip()!
			.Tracks.Single( x => x.Target == "slide" );
		Check(
			report,
			lifecycleTrack.CurveSpans.Any( x => x.Id == lifecycleSpanId ),
			"Moving curve endpoints must retain their stable span data." );

		lifecycleController.CopySelectedKeys();
		lifecycleController.SetTimelineFrame( 5 );
		lifecycleController.PasteKeys();
		lifecycleTrack = lifecycleController.Document.GetSelectedClip()!
			.Tracks.Single( x => x.Target == "slide" );
		Check(
			report,
			lifecycleTrack.CurveSpans.Any( x =>
				x.HasSpeedCurve
					&& lifecycleController.SelectedKeys.Contains( x.StartKeyId )
					&& lifecycleController.SelectedKeys.Contains( x.EndKeyId ) ),
			"Copy and paste must preserve a span curve only when both endpoint keys are copied." );

		var invalidDocument = ValidDocument();
		var invalidClip = invalidDocument.EnsureClip( WeaponClipRole.Fire );
		var invalidTrack = invalidClip.EnsureTrack( "weapon_root" );
		var invalidStart = WeaponAnimationMath.UpsertKey(
			invalidTrack, 0, new Transform( Vector3.Zero ) );
		var invalidEnd = WeaponAnimationMath.UpsertKey(
			invalidTrack, 1, new Transform( Vector3.One ) );
		var invalidSpan = invalidTrack.EnsureCurveSpan(
			invalidStart.Id, invalidEnd.Id );
		invalidSpan.HasSpeedCurve = true;
		invalidSpan.Speed = new MotionRateCurve
		{
			StartRate = -1,
			EndRate = -1
		};
		Check(
			report,
			WeaponAnimationValidator.ValidateForGeneration( invalidDocument )
				.Issues.Any( x => x.Code == "curve.speed_invalid" ),
			"Zero-area speed curves must produce an explicit validation warning." );

		var exportDocument = WeaponAnimationDocument.CreateDefault( "Curve export" );
		var exportClip = exportDocument.GetSelectedClip()!;
		exportClip.Duration = 1;
		exportClip.SampleRate = 30;
		exportClip.IsBindPoseSeed = false;
		exportClip.Tracks.Clear();
		var exportTrack = exportClip.EnsureTrack( "root" );
		WeaponAnimationMath.UpsertKey(
			exportTrack, 0, new Transform( Vector3.Zero ) );
		WeaponAnimationMath.UpsertKey(
			exportTrack, 1, new Transform( new Vector3( 8, 0, 0 ) ) );
		CurveEditingService.ApplyPreset(
			exportTrack,
			[],
			CurveEditorMode.Speed,
			TransformCurveChannel.PositionX,
			CurvePreset.EaseInOut );
		var exportSkeleton = new HostSkeleton();
		exportSkeleton.Add( new HostBone
		{
			Name = "root",
			BindModelTransform = Transform.Zero
		} );
		var firstExport = DmxWriter.WriteAnimation(
			exportDocument, exportSkeleton, exportClip );
		var secondExport = DmxWriter.WriteAnimation(
			exportDocument, exportSkeleton, exportClip );
		Equal(
			report,
			firstExport,
			secondExport,
			"Customized curves must produce deterministic sampled animation output." );
	}

	private static void TestFrameSnapping( WeaponAnimatorSelfTestReport report )
	{
		Near( report, 10.0f / 30.0f, WeaponAnimationMath.SnapTime( 0.34f, 30, false ), 0.0001f, "Frame snapping must select the nearest frame." );
		Near( report, 0.34f, WeaponAnimationMath.SnapTime( 0.34f, 30, true ), 0.0001f, "Subframe keys must preserve time." );
	}

	private static void TestTimelineNavigation( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Timeline navigation" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 10;
		clip.SampleRate = 30;
		var full = TimelineInteraction.ResolveRange( clip, null );
		Equal( report, 0, full.StartFrame, "A new timeline view must begin at frame zero." );
		Equal( report, 300, full.EndFrame, "A new timeline view must cover the complete clip." );

		var zoomed = TimelineInteraction.Zoom( new TimelineFrameRange( 60, 240 ), 300, true );
		Equal( report, 144, zoomed.Span, "Ctrl+wheel zoom must reduce the visible frame span." );
		Equal(
			report,
			300,
			zoomed.StartFrame + zoomed.EndFrame,
			"Ctrl+wheel zoom must preserve the range midpoint." );
		var panned = TimelineInteraction.Pan( zoomed, 500, 300 );
		Equal( report, 300, panned.EndFrame, "Range panning must clamp at the clip end." );
		var minimum = TimelineInteraction.ResizeStart(
			new TimelineFrameRange( 0, 10 ),
			10,
			300 );
		Equal(
			report,
			TimelineInteraction.MinimumVisibleFrameIntervals,
			minimum.Span,
			"Range handles must retain the minimum two-frame interval." );

		var closeTicks = TimelineInteraction.TickSpacing( 10 );
		var wideTicks = TimelineInteraction.TickSpacing( 0.5f );
		Equal( report, 1, closeTicks.MinorFrames, "Zoomed timelines must expose individual frame ticks." );
		Check(
			report,
			wideTicks.MinorFrames > closeTicks.MinorFrames
				&& wideTicks.MajorFrames > closeTicks.MajorFrames,
			"Tick spacing must become coarser as the visible frame density increases." );
		var marker = TimelineInteraction.KeyMarkerPosition(
			337.42f, 44, 100, 500, TimelineEditorCanvas.TrackHeight );
		Near(
			report,
			337,
			marker.X,
			0.0001f,
			"Key markers must snap horizontally to whole pixels." );
		Near(
			report,
			55,
			marker.Y,
			0.0001f,
			"Key markers must remain vertically centered on their row." );
		Near(
			report,
			105,
			TimelineInteraction.KeyMarkerPosition(
				100, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,
			0.0001f,
			"First-frame diamonds must remain fully inside the graph." );
		Near(
			report,
			495,
			TimelineInteraction.KeyMarkerPosition(
				500, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,
			0.0001f,
			"Last-frame diamonds must not be covered by the scrollbar gutter." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetTimelineRange( clip, new TimelineFrameRange( 30, 90 ) );
		controller.SetTimelineVerticalScroll( clip, 132 );
		var state = document.Workspace.GetTimelineView( clip.Id );
		Check(
			report,
			state is not null,
			"Changing a timeline view must create its per-clip workspace state." );
		Near( report, 1, state!.VisibleStart, 0.0001f, "Timeline range start must persist in seconds." );
		Near( report, 3, state.VisibleEnd, 0.0001f, "Timeline range end must persist in seconds." );
		Near( report, 132, state.VerticalScroll, 0.0001f, "Vertical track scroll must persist per clip." );

		clip.Tracks.Add( new TransformTrack { Target = "one" } );
		clip.Tracks.Add( new TransformTrack { Target = "two" } );
		document.Rig.VisibilityParts.Add( new WeaponVisibilityPart() );
		Equal(
			report,
			4,
			TimelineInteraction.TrackRowCount( document, clip ),
			"Timeline row count must include every transform track, visibility track, and the tag row." );
	}

	private static void TestTimelineSelectionAndMovement( WeaponAnimatorSelfTestReport report )
	{
		var first = Guid.NewGuid();
		var second = Guid.NewGuid();
		var third = Guid.NewGuid();
		var replaced = TimelineInteraction.CombineKeySelection(
			[first],
			[second, third],
			additive: false,
			toggle: false );
		Check(
			report,
			replaced.SetEquals( [second, third] ),
			"A plain marquee must replace the previous key selection." );
		var added = TimelineInteraction.CombineKeySelection(
			[first],
			[second],
			additive: true,
			toggle: false );
		Check(
			report,
			added.SetEquals( [first, second] ),
			"Shift-marquee must add intersected keys." );
		var toggled = TimelineInteraction.CombineKeySelection(
			[first, second],
			[second, third],
			additive: false,
			toggle: true );
		Check(
			report,
			toggled.SetEquals( [first, third] ),
			"Ctrl-marquee must toggle every intersected key." );
		var scrolledMarquee = TimelineInteraction.ProjectMarquee(
			startX: 220,
			startContentY: 400,
			currentX: 520,
			currentContentY: 290,
			verticalScroll: 40,
			minimumX: 180,
			maximumX: 500 );
		Near(
			report,
			360,
			scrolledMarquee.Bottom,
			0.0001f,
			"A marquee start must remain anchored to its original track while scrolling." );
		Near(
			report,
			250,
			scrolledMarquee.Top,
			0.0001f,
			"A scrolling marquee endpoint must follow the newly revealed content." );
		Near(
			report,
			500,
			scrolledMarquee.Right,
			0.0001f,
			"A marquee must remain clipped to the graph's right edge." );
		Equal(
			report,
			-2,
			TimelineInteraction.ClampGroupFrameDelta( [2, 5], -20, 30 ),
			"Moving keys before frame zero must clamp the group as a unit." );
		Equal(
			report,
			25,
			TimelineInteraction.ClampGroupFrameDelta( [2, 5], 40, 30 ),
			"Moving keys past the clip end must preserve their internal spacing." );

		var document = WeaponAnimationDocument.CreateDefault( "Timeline key move" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 1;
		clip.SampleRate = 30;
		var track = clip.EnsureTrack( "weapon_root" );
		var keyA = WeaponAnimationMath.UpsertKey( track, 2f / 30, Transform.Zero );
		var keyB = WeaponAnimationMath.UpsertKey( track, 5f / 30, Transform.Zero );
		WeaponAnimationMath.UpsertKey( track, 7f / 30, Transform.Zero );
		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetSelectedKeys( [keyA.Id, keyB.Id] );
		var starts = new Dictionary<Guid, float>
		{
			[keyA.Id] = keyA.Time,
			[keyB.Id] = keyB.Time
		};
		controller.BeginSelectedKeyMove();
		controller.UpdateSelectedKeyMove( starts, 2 );
		controller.EndSelectedKeyMove( starts, 2 );
		Equal(
			report,
			2,
			track.Keys.Count,
			"A moved key must replace an unselected key occupying its destination frame." );
		Check(
			report,
			track.Keys.Select( x => TimelineInteraction.TimeToFrame( x.Time, 30 ) )
				.SequenceEqual( [4, 7] ),
			"Selected keys must move by the same snapped frame delta." );
		controller.Undo();
		clip = controller.Document.GetSelectedClip()!;
		Equal(
			report,
			3,
			clip.EnsureTrack( "weapon_root" ).Keys.Count,
			"A complete key drag must undo as one action." );

		var deleteDocument = WeaponAnimationDocument.CreateDefault( "Timeline key delete" );
		var deleteClip = deleteDocument.GetSelectedClip()!;
		var deleteTransformKey = WeaponAnimationMath.UpsertKey(
			deleteClip.EnsureTrack( "weapon_root" ),
			0,
			Transform.Zero );
		var visibilityPart = new WeaponVisibilityPart { Name = "Magazine" };
		deleteDocument.Rig.VisibilityParts.Add( visibilityPart );
		var deleteVisibilityKey = new VisibilityKey { Time = 0, Visible = false };
		deleteClip.EnsureVisibilityTrack( visibilityPart.Id ).Keys.Add( deleteVisibilityKey );
		var deleteController = new WeaponAnimatorController();
		deleteController.SetDocument( deleteDocument );
		deleteController.SetSelectedKeys( [deleteTransformKey.Id, deleteVisibilityKey.Id] );
		deleteController.DeleteSelectedKeys();
		deleteClip = deleteController.Document.GetSelectedClip()!;
		Equal(
			report,
			0,
			deleteClip.Tracks.SelectMany( x => x.Keys ).Count(),
			"Deleting selected keys must remove transform keys." );
		Equal(
			report,
			0,
			deleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),
			"Deleting selected keys must remove visibility keys." );
		Equal(
			report,
			0,
			deleteController.SelectedKeys.Count,
			"Deleting keys must clear the stale key selection." );
		deleteController.Undo();
		deleteClip = deleteController.Document.GetSelectedClip()!;
		Equal(
			report,
			2,
			deleteClip.Tracks.SelectMany( x => x.Keys ).Count()
				+ deleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),
			"Deleting a mixed key selection must undo as one action." );
	}

	private static void TestTimelineKeyReversal( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Timeline reverse" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 1;
		clip.SampleRate = 30;
		var track = clip.EnsureTrack( "weapon_root" );
		track.Interpolation = TrackInterpolation.Linear;
		var start = WeaponAnimationMath.UpsertKey(
			track,
			0,
			new Transform( new Vector3( 0, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var end = WeaponAnimationMath.UpsertKey(
			track,
			1,
			new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );
		start.CurveTangents.PositionOut = new Vector3( 4, 0, 0 );
		end.CurveTangents.PositionIn = new Vector3( 12, 0, 0 );
		var span = track.EnsureCurveSpan( start.Id, end.Id );
		span.CustomChannels = TransformCurveChannel.PositionX;
		span.HasSpeedCurve = true;
		span.Speed = new MotionRateCurve
		{
			StartRate = 0.4f,
			EndRate = 1.6f,
			StartSlope = 0.5f,
			EndSlope = -0.25f,
			StartHandleMode = CurveHandleMode.Free,
			EndHandleMode = CurveHandleMode.Aligned
		};
		var sampleTimes = new[] { 0.0f, 0.2f, 0.5f, 0.8f, 1.0f };
		var sourceSamples = sampleTimes
			.Select( x => WeaponAnimationMath.SampleTrack( track, x, Transform.Zero ).Position )
			.ToArray();

		var visibilityPart = new WeaponVisibilityPart { Name = "Magazine" };
		document.Rig.VisibilityParts.Add( visibilityPart );
		var visibility = clip.EnsureVisibilityTrack( visibilityPart.Id );
		var hidden = new VisibilityKey { Time = 0, Visible = false };
		var shown = new VisibilityKey { Time = 1, Visible = true };
		visibility.Keys.AddRange( [hidden, shown] );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.ReverseKeys();
		clip = controller.Document.GetSelectedClip()!;
		track = clip.EnsureTrack( "weapon_root" );
		Equal(
			report,
			end.Id,
			track.Keys[0].Id,
			"With no key selection, Reverse must flip all transform keys across the clip." );
		Equal(
			report,
			start.Id,
			track.Keys[^1].Id,
			"Whole-clip reversal must place the first key at the last frame." );
		var reversedSpan = track.FindCurveSpan( end.Id, start.Id );
		Check(
			report,
			reversedSpan is not null,
			"Custom curve spans must reverse with their endpoint keys." );
		if ( reversedSpan is not null )
		{
			Near(
				report,
				span.Speed.EndRate,
				reversedSpan.Speed.StartRate,
				0.0001f,
				"Reversing a speed curve must swap its endpoint rates." );
			Near(
				report,
				-span.Speed.EndSlope,
				reversedSpan.Speed.StartSlope,
				0.0001f,
				"Reversing a speed curve must invert its former end slope." );
			Equal(
				report,
				span.Speed.EndHandleMode,
				reversedSpan.Speed.StartHandleMode,
				"Reversing a speed curve must swap its handle modes." );
		}
		Near(
			report,
			-12,
			track.Keys[0].CurveTangents.PositionOut.x,
			0.0001f,
			"Reversed channel curves must negate the former incoming tangent." );
		Near(
			report,
			-4,
			track.Keys[^1].CurveTangents.PositionIn.x,
			0.0001f,
			"Reversed channel curves must negate the former outgoing tangent." );
		for ( var i = 0; i < sampleTimes.Length; i++ )
		{
			Near(
				report,
				sourceSamples[^(i + 1)],
				WeaponAnimationMath.SampleTrack(
					track,
					sampleTimes[i],
					Transform.Zero ).Position,
				0.01f,
				"Reversed custom curves must reproduce the original motion backward." );
		}
		visibility = clip.EnsureVisibilityTrack( visibilityPart.Id );
		Equal(
			report,
			shown.Id,
			visibility.Keys[0].Id,
			"Whole-clip reversal must also flip visibility keys." );

		controller.Undo();
		clip = controller.Document.GetSelectedClip()!;
		track = clip.EnsureTrack( "weapon_root" );
		Equal(
			report,
			start.Id,
			track.Keys[0].Id,
			"Transform, curve, and visibility reversal must undo as one action." );

		var selectionDocument = WeaponAnimationDocument.CreateDefault( "Selected reverse" );
		var selectionClip = selectionDocument.GetSelectedClip()!;
		selectionClip.Duration = 2;
		selectionClip.SampleRate = 10;
		var selectionTrack = selectionClip.EnsureTrack( "slide" );
		var first = WeaponAnimationMath.UpsertKey(
			selectionTrack,
			0.2f,
			new Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var middle = WeaponAnimationMath.UpsertKey(
			selectionTrack,
			0.6f,
			new Transform( new Vector3( 6, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var last = WeaponAnimationMath.UpsertKey(
			selectionTrack,
			1.0f,
			new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var selectionController = new WeaponAnimatorController();
		selectionController.SetDocument( selectionDocument );
		selectionController.SetSelectedKeys( [first.Id, last.Id] );
		selectionController.ReverseKeys();
		selectionTrack = selectionController.Document.GetSelectedClip()!.EnsureTrack( "slide" );
		Equal(
			report,
			last.Id,
			selectionTrack.Keys[0].Id,
			"Selected reversal must flip keys around the selected range, not the clip bounds." );
		Equal(
			report,
			middle.Id,
			selectionTrack.Keys[1].Id,
			"Keys outside the reversed selection must retain their frame." );
		Equal(
			report,
			first.Id,
			selectionTrack.Keys[2].Id,
			"Selected reversal must preserve key identities and selection." );
		Check(
			report,
			selectionController.SelectedKeys.ToHashSet().SetEquals( [first.Id, last.Id] ),
			"Reversed keys must remain selected for immediate follow-up editing." );
	}

	private static void TestTimelinePlayback( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Timeline playback" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 1;
		clip.SampleRate = 30;
		clip.Loop = false;
		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );

		controller.SetTimelineTime( 0.01f );
		Near( report, 0, document.Workspace.TimelineTime, 0.0001f, "Timeline seeking must reject fractional-frame positions." );
		controller.SetTimelineTime( 0.02f );
		Near( report, 1f / 30, document.Workspace.TimelineTime, 0.0001f, "Timeline seeking must snap to the nearest whole frame." );
		controller.JumpToLastFrame();
		controller.TogglePlayback();
		Check( report, controller.IsPlaying, "Play must enter the shared playback state." );
		Near( report, 0, document.Workspace.TimelineTime, 0.0001f, "Playing from the last frame must restart at frame zero." );
		controller.AdvancePlayback( 0.04f );
		Equal(
			report,
			1,
			TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
			"Playback must advance through whole-frame preview positions." );
		var movingTrack = clip.EnsureTrack( "weapon_root" );
		var movingKey = WeaponAnimationMath.UpsertKey(
			movingTrack,
			0.2f,
			new Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );
		controller.SetSelectedKeys( [movingKey.Id] );
		controller.BeginSelectedKeyMove();
		controller.UpdateSelectedKeyMove(
			new Dictionary<Guid, float> { [movingKey.Id] = movingKey.Time },
			1 );
		Check(
			report,
			controller.IsPlaying,
			"Selecting and dragging a key must not pause viewport playback." );
		controller.EndSelectedKeyMove(
			new Dictionary<Guid, float> { [movingKey.Id] = 0.2f },
			1 );
		Check(
			report,
			controller.IsPlaying,
			"Committing a key drag must leave playback running for live SampleTrack checks." );
		controller.StepTimelineFrame( 1 );
		Check( report, !controller.IsPlaying, "Manual frame stepping must pause playback." );
		Equal(
			report,
			2,
			TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
			"Next-frame controls must advance exactly one frame." );
		controller.JumpToLastFrame();
		controller.StepTimelineFrame( 1 );
		Equal(
			report,
			30,
			TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
			"Frame stepping must clamp at the final frame." );
		controller.ToggleSelectedClipLoop();
		Check(
			report,
			clip.Loop,
			"The selected clip loop state must be editable through the shared controller." );
		controller.TogglePlayback();
		controller.AdvancePlayback( 1.1f );
		Check(
			report,
			controller.IsPlaying
				&& TimelineInteraction.TimeToFrame(
					document.Workspace.TimelineTime,
					clip.SampleRate ) == 3,
			"Looped playback must wrap and remain active." );
		controller.Undo();
		Check(
			report,
			!controller.Document.GetSelectedClip()!.Loop,
			"Changing the loop state must be one undoable action." );
	}

	private static void TestTwoBoneIk( WeaponAnimatorSelfTestReport report )
	{
		var reachable = WeaponAnimationMath.SolveTwoBone(
			Vector3.Zero,
			Vector3.Forward,
			Vector3.Forward * 2,
			new Vector3( 1.5f, 0.4f, 0 ),
			Vector3.Up );
		Check( report, reachable.Reachable, "An in-range hand target must be reachable." );
		Near( report, new Vector3( 1.5f, 0.4f, 0 ), reachable.End, 0.001f, "Reachable target must be solved exactly." );

		var clamped = WeaponAnimationMath.SolveTwoBone(
			Vector3.Zero,
			Vector3.Forward,
			Vector3.Forward * 2,
			Vector3.Forward * 10,
			Vector3.Up );
		Check( report, !clamped.Reachable, "An overextended target must be reported." );
		Check( report, clamped.SolvedDistance < 2, "Overextension must clamp below total arm length." );
	}

	private static void TestConstraintDrivenIk( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.IsBound = true;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );

		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "bolt", "root", new Vector3( 1.2f, 0.8f, 0 ) ) );

		var clip = document.EnsureClip( WeaponClipRole.Idle );
		clip.Constraints.Add( new TimedConstraint
		{
			SourceControl = "@primary_hand",
			TargetBone = "bolt",
			StartTime = 0,
			EndTime = 1,
			MaintainOffset = false
		} );
		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0.5f );
		Near( report, new Vector3( 1.2f, 0.8f, 0 ), pose.Model["hand_R"].Position, 0.002f, "Constraint must drive the IK target before the arm solve." );
	}

	private static void TestIkDescendantPropagation( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.IsBound = true;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.2f, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );

		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "finger_R", "hand_R", new Vector3( 2.5f, 0.2f, 0 ) ) );
		skeleton.Add( Bone( "forearm_twist_R", "arm_lower_R", new Vector3( 1.5f, 0, 0 ) ) );

		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		var fingerLocal = skeleton.GetBindLocal( skeleton.ByName["finger_R"] );
		var twistLocal = skeleton.GetBindLocal( skeleton.ByName["forearm_twist_R"] );
		Near(
			report,
			pose.Model["hand_R"].PointToWorld( fingerLocal.Position ),
			pose.Model["finger_R"].Position,
			0.001f,
			"Finger descendants must follow the solved hand." );
		Near(
			report,
			pose.Model["arm_lower_R"].PointToWorld( twistLocal.Position ),
			pose.Model["forearm_twist_R"].Position,
			0.001f,
			"Twist descendants must follow the solved forearm." );
	}

	private static void TestConstraintMaintainedOffset( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.IsBound = true;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );

		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "bolt", "root", new Vector3( 1, 0, 0 ) ) );

		var clip = document.EnsureClip( WeaponClipRole.Idle );
		var boltTrack = clip.EnsureTrack( "bolt" );
		WeaponAnimationMath.UpsertKey( boltTrack, 0, new Transform( new Vector3( 1, 0, 0 ) ) );
		WeaponAnimationMath.UpsertKey( boltTrack, 1, new Transform( new Vector3( 1.2f, 0, 0 ) ) );
		clip.Constraints.Add( new TimedConstraint
		{
			SourceControl = "@primary_hand",
			TargetBone = "bolt",
			StartTime = 0,
			EndTime = 1,
			MaintainOffset = true
		} );

		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 1 );
		Near( report, new Vector3( 1.7f, 0, 0 ), pose.Model["hand_R"].Position, 0.002f, "Maintain-offset constraints must preserve the start-frame hand offset." );
	}

	private static void TestHostSkeletonCache( WeaponAnimatorSelfTestReport report )
	{
		HostSkeletonBuilder.ClearCache();
		var document = ValidDocument();
		var first = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		var second = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			ReferenceEquals( first, second ),
			"An unchanged document must reuse the cached host skeleton." );

		// Calibration nudges can be far below display precision, so the signature must compare
		// exact float bits rather than a rounded or formatted value.
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithPosition(
				new Vector3( 0.0000001f, 0, 0 ) );
		var afterTinyMove = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( first, afterTinyMove ),
			"A sub-precision calibration change must still invalidate the cached skeleton." );

		document.Rig.Bones[0].BindModelTransform =
			document.Rig.Bones[0].BindModelTransform.WithScale( 1.0000001f );
		var afterBoneChange = HostSkeletonBuilder.BuildCached(
			document,
			includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( afterTinyMove, afterBoneChange ),
			"A bone bind change must invalidate the cached skeleton." );

		document.Binding.PrimaryHand.Transform =
			document.Binding.PrimaryHand.Transform.WithPosition( new Vector3( 3, 2, 1 ) );
		var afterBindingChange = HostSkeletonBuilder.BuildCached(
			document,
			includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( afterBoneChange, afterBindingChange ),
			"A hand binding change must invalidate the cached skeleton." );

		var reread = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			ReferenceEquals( afterBindingChange, reread ),
			"Rebuilding after a change must repopulate the cache rather than rebuild every call." );
		HostSkeletonBuilder.ClearCache();
	}

	private static void TestControllerHistoryAndClipboard( WeaponAnimatorSelfTestReport report )
	{
		var controller = new WeaponAnimatorController();
		controller.SetDocument( WeaponAnimationDocument.CreateDefault( "History" ) );
		controller.Mutate( "Rename", document => document.Name = "Changed" );
		Check( report, controller.IsDirty && controller.CanUndo, "A mutation must mark the document dirty and create undo history." );
		controller.Undo();
		Equal( report, "History", controller.Document.Name, "Undo must restore the previous snapshot." );
		controller.Redo();
		Equal( report, "Changed", controller.Document.Name, "Redo must restore the changed snapshot." );
		var documentEvents = 0;
		var poseEvents = 0;
		var selectionEvents = 0;
		var keySelectionEvents = 0;
		controller.DocumentChanged += () => documentEvents++;
		controller.PoseChanged += () => poseEvents++;
		controller.SelectionChanged += () => selectionEvents++;
		controller.KeySelectionChanged += () => keySelectionEvents++;
		controller.BeginContinuousEdit( "Scrub name" );
		controller.UpdateContinuousEdit( document => document.Name = "Scrub A" );
		controller.UpdateContinuousEdit( document => document.Name = "Scrub B" );
		Equal(
			report,
			0,
			documentEvents,
			"A live scrub must not broadcast full document rebuilds while dragging." );
		Equal(
			report,
			2,
			poseEvents,
			"A live scrub must publish lightweight pose previews." );
		controller.EndContinuousEdit();
		Equal(
			report,
			1,
			documentEvents,
			"Completing a scrub must publish one consolidated document change." );
		controller.Undo();
		Equal( report, "Changed", controller.Document.Name, "A continuous drag must collapse into one undo step." );
		controller.Redo();
		Equal( report, "Scrub B", controller.Document.Name, "Redo must restore the final continuous-drag value." );

		var clip = controller.Document.GetSelectedClip()!;
		var track = clip.EnsureTrack( "weapon_root" );
		var key = WeaponAnimationMath.UpsertKey( track, 0, new Transform( new Vector3( 1, 2, 3 ) ) );
		var selectionBeforeKeys = selectionEvents;
		controller.SelectKeys( [key.Id], false );
		Equal(
			report,
			selectionBeforeKeys,
			selectionEvents,
			"Key selection must not broadcast a control-selection rebuild." );
		Check(
			report,
			keySelectionEvents > 0,
			"Key selection must publish its dedicated lightweight event." );
		controller.CopySelectedKeys();
		controller.SetTimelineTime( 0.5f );
		controller.PasteKeys();
		clip = controller.Document.GetSelectedClip()!;
		Equal( report, 2, clip.EnsureTrack( "weapon_root" ).Keys.Count, "Pasting keys must duplicate the clipboard payload." );
		Near(
			report,
			0.5f,
			clip.EnsureTrack( "weapon_root" ).Keys.Max( x => x.Time ),
			0.0001f,
			"Pasted keys must be offset to the playhead." );

		var keyController = new WeaponAnimatorController();
		var keyDocument = ValidDocument();
		keyController.SetDocument( keyDocument );
		keyController.SelectBone( "weapon_root" );
		keyController.SetTimelineTime( 0.5f );
		keyController.KeySelectedTransform();
		Check(
			report,
			keyController.Document.GetSelectedClip()!.Tracks
				.Single( current => current.Target == "weapon_root" )
				.Keys.Any( current => MathF.Abs( current.Time - 0.5f ) < 0.0001f ),
			"The shared K/Add Key command must key a selected weapon bone." );
	}

	private static void TestValidation( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		Check( report, WeaponAnimationValidator.ValidateCalibration( document ).IsValid, "A complete calibration should pass." );
		Check( report, WeaponAnimationValidator.ValidateForGeneration( document ).IsValid, "Idle-only generation should pass with action warnings." );
		Check(
			report,
			WeaponAnimationValidator.ValidateForGeneration( document ).Issues.Any( x =>
				x.Severity == ValidationSeverity.Warning && x.Code == "clip.fallback" ),
			"Missing action clips must remain warnings." );
		document.Source.SourcePath = "weapons/test/source.smd";
		var smdValidation = WeaponAnimationValidator.ValidateForGeneration( document );
		Check(
			report,
			smdValidation.Issues.Any( issue =>
				issue.Blocking && issue.Code == "source.not_embeddable" ),
			"SMD projects must explain the ModelDoc generation limitation before Generate runs." );
		Check(
			report,
			smdValidation.Issues.Any( issue =>
				issue.Code == "source.not_embeddable"
				&& issue.Message.Contains( "SMD", StringComparison.Ordinal ) ),
			"The generation-format diagnostic must name the unsupported source extension." );
		document.Source.SourcePath = "weapons/test/source.vmdl";
		Check(
			report,
			WeaponAnimationValidator.ValidateForGeneration( document ).Issues.All( issue =>
				issue.Code != "source.not_embeddable" ),
			"VMDL projects must pass source-format validation through the generated adapter path." );
		document.Source.SourcePath = "weapons/test/source.fbx";
		document.Calibration.Anchors.RemoveAll( anchor =>
			anchor.Kind is AnchorKind.RearBore or AnchorKind.FrontBore );
		Check(
			report,
			WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
			"Auto-align markers must not block an already-oriented weapon." );

		document.Source.OriginalModelDimensions = Vector3.One;
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithScale( 1 );
		Check(
			report,
			WeaponAnimationValidator.ValidateCalibration( document ).Issues.Any( issue =>
				issue.Code == "scale.implausible" ),
			"Implausible-scale validation must use persisted source bounds without requiring a measurement." );

		document.Rig.Bones.Add( new WeaponBoneDefinition { Name = "hand_R" } );
			Check(
				report,
				!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
				"Facepunch-reserved weapon bone names must block calibration." );

			document.Rig.Bones.RemoveAt( document.Rig.Bones.Count - 1 );
			document.Rig.Bones[0].Name = "root";
			document.Rig.Bones[0].Classification = WeaponBoneClassification.WeaponRoot;
			document.Rig.RootBone = "root";
			Check(
				report,
				WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
				"A classified source root may use a reserved name before wrapper normalization." );
	}

	private static void TestGenerationOutputPaths( WeaponAnimatorSelfTestReport report )
	{
		var contentRoot = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-output-{Guid.NewGuid():N}",
			"Assets" );
		var document = WeaponAnimationDocument.CreateDefault( "Output Test" );
		var defaultOutput = AssetGenerationService.ResolveOutputRootForContentRoot(
			document,
			contentRoot );
		Equal(
			report,
			Path.GetFullPath( Path.Combine(
				contentRoot,
				"weapons",
				"output_test",
				"viewmodel" ) ),
			defaultOutput,
			"Default generation output must resolve beneath Assets even before the folder exists." );

		document.Output.OutputFolder = "/weapons/custom/viewmodel";
		Equal(
			report,
			Path.GetFullPath( Path.Combine(
				contentRoot,
				"weapons",
				"custom",
				"viewmodel" ) ),
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),
			"A leading asset slash must remain a project-relative output path." );

		document.Output.OutputFolder = "../outside";
		var rejectedEscape = false;
		try
		{
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );
		}
		catch ( InvalidOperationException )
		{
			rejectedEscape = true;
		}
		Check(
			report,
			rejectedEscape,
			"Generation output must reject paths that escape the project's Assets folder." );

		document.Output.OutputFolder = "C:/outside";
		var rejectedDrive = false;
		try
		{
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );
		}
		catch ( InvalidOperationException )
		{
			rejectedDrive = true;
		}
		Check(
			report,
			rejectedDrive,
			"Generation output must reject absolute drive paths on every host platform." );

		var nestedOutputRoot = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-nested-output-{Guid.NewGuid():N}" );
		try
		{
			AssetGenerationService.WriteTextSourcesForTests(
				nestedOutputRoot,
				new Dictionary<string, string>
				{
					["materials/output_test_body.vmat"] = "fixture material",
					["output_test_vm.vmdl"] = "fixture model"
				} );
			Check(
				report,
				File.Exists( Path.Combine(
					nestedOutputRoot,
					"materials",
					"output_test_body.vmat" ) ),
				"Generation must create parent directories for nested material sources." );
		}
		finally
		{
			if ( Directory.Exists( nestedOutputRoot ) )
				Directory.Delete( nestedOutputRoot, true );
		}

		document.Output = null!;
		Equal(
			report,
			Path.GetFullPath( Path.Combine(
				contentRoot,
				"weapons",
				"output_test",
				"viewmodel" ) ),
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),
			"Generation must repair missing output settings instead of throwing." );
	}

	private static void TestGeneratedFileRemoval( WeaponAnimatorSelfTestReport report )
	{
		var root = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-removal-{Guid.NewGuid():N}" );
		Directory.CreateDirectory( root );
		try
		{
			var host = Path.Combine( root, "weapon_host.vmdl" );
			var clip = Path.Combine( root, "weapon_idle.dmx" );
			var graph = Path.Combine( root, "weapon.vanmgrph" );
			var prefab = Path.Combine( root, "v_weapon.prefab" );
			foreach ( var file in new[] { host, clip, graph, prefab } )
			{
				File.WriteAllText( file, "generated" );
				File.WriteAllText( $"{file}_c", "compiled" );
			}

			AssetGenerationService.DeleteGeneratedFiles( [clip, host, graph, prefab] );

			Check(
				report,
				!File.Exists( host ) && !File.Exists( $"{host}_c" ),
				"Removing a generated asset must take its compiled artifact with it." );
			Check(
				report,
				!File.Exists( clip ) && !File.Exists( graph ) && !File.Exists( prefab ),
				"Every listed generated file must be removed." );

			// The dependant .vmdl has to be gone before its .dmx sources, or the asset system
			// keeps recompiling a model whose animation dependencies stopped existing.
			File.WriteAllText( host, "generated" );
			File.WriteAllText( clip, "generated" );
			var ordered = AssetGenerationService.OrderForRemoval( [clip, host] ).ToList();
			Equal(
				report,
				host,
				ordered[0],
				"Compiled dependants must be removed before the sources they consume." );
				var lifecycleFiles = new[]
				{
					"weapon_sequence_idle.dmx",
					"weapon_source_adapter.vmdl",
					"weapon_vm_bootstrap.vmdl",
					"weapon.vanmgrph",
				"weapon_vm.vmdl",
				"v_weapon.prefab"
			};
			var writeOrder = AssetGenerationService.OrderForWrite( lifecycleFiles ).ToList();
			Equal(
				report,
				string.Join( "|", lifecycleFiles ),
				string.Join( "|", writeOrder ),
				"Generated sources must appear in dependency order so automatic compilation never observes a missing preview host." );
			var removeOrder = AssetGenerationService.OrderForRemoval( lifecycleFiles ).ToList();
			Equal(
					report,
					"v_weapon.prefab|weapon_vm.vmdl|weapon.vanmgrph|weapon_vm_bootstrap.vmdl|weapon_source_adapter.vmdl|weapon_sequence_idle.dmx",
				string.Join( "|", removeOrder ),
				"Generated consumers must be removed in reverse dependency order." );
			Check(
				report,
				!AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
					"weapon_sprint.dmx",
					previouslyOwned: true ),
				"Rollback must retain a recreated owned DMX dependency needed by an older host." );
			Check(
				report,
				AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
					"weapon_vm.vmdl",
					previouslyOwned: true )
				&& AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
					"new_clip.dmx",
					previouslyOwned: false ),
				"Rollback must remove failed compiled consumers and newly introduced dependencies." );

			var freshnessSource = Path.Combine( root, "freshness.vmdl" );
			var freshnessCompiled = freshnessSource + "_c";
			File.WriteAllText( freshnessSource, "source" );
			File.WriteAllText( freshnessCompiled, "compiled" );
			var now = DateTime.UtcNow;
			File.SetLastWriteTimeUtc( freshnessSource, now );
			File.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( 1 ) );
			Check(
				report,
				AssetGenerationService.IsFreshCompiledArtifact(
					freshnessSource,
					freshnessCompiled ),
				"A newly written compiled artifact must complete generation even while its managed Asset wrapper is stale." );
			File.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( -10 ) );
			Check(
				report,
				!AssetGenerationService.IsFreshCompiledArtifact(
					freshnessSource,
					freshnessCompiled ),
				"An artifact older than its regenerated source must never be accepted as compile success." );
		}
		finally
		{
			Directory.Delete( root, true );
		}
	}

	private static void TestMaterialPipeline( WeaponAnimatorSelfTestReport report )
	{
		var embeddedMaterials = WeaponMaterialPipeline.MatchEmbeddedMaterialNamesForTests(
			["HK_P30L", "cartridge"],
			["Material", "H&K_P30L", "cartridge", "cartridge_BaseColor"] );
		Check(
			report,
			embeddedMaterials.Contains( "H&K_P30L" )
				&& embeddedMaterials.Contains( "cartridge" )
				&& embeddedMaterials.Count == 2,
			"Embedded FBX labels must preserve special characters when matching texture-set names." );

		var discovered = WeaponMaterialPipeline.DiscoverForTests(
			[
				"H&K_P30L.vmat",
				"cartridge.vmat",
				"materials/error.vmat"
			],
			[
				"/fixture/Textures/HK_P30L_BaseColor.png",
				"/fixture/Textures/HK_P30L_Normal_GL.png",
				"/fixture/Textures/HK_P30L_Normal_DX.png",
				"/fixture/Textures/HK_P30L_Roughness.png",
				"/fixture/Textures/HK_P30L_Metallic.png",
				"/fixture/Textures/cartridge_BaseColor.png",
				"/fixture/Textures/cartridge_Normal_DX.png"
			] );
		Equal(
			report,
			2,
			discovered.Count,
			"Nearby texture discovery must retain every FBX material slot." );
		var pistol = discovered.Single( material => material.Name == "H&K_P30L" );
		Check(
			report,
			pistol.FindTexture( WeaponTextureChannel.Normal )?.AssetPath
				.EndsWith( "Normal_GL.png", StringComparison.OrdinalIgnoreCase ) == true,
			"S&box-compatible OpenGL normal maps must win when both GL and DX variants are available." );
		Check(
			report,
			pistol.FindTexture( WeaponTextureChannel.Metalness ) is not null
				&& discovered.Single( material => material.Name == "cartridge" )
					.FindTexture( WeaponTextureChannel.BaseColor ) is not null,
			"Texture sets must be matched independently to their source material names." );
		Check(
			report,
			discovered.All( material => !material.SourceMaterialPath.Equals(
				"materials/error",
				StringComparison.OrdinalIgnoreCase ) ),
			"The compiler error material must never become a generated weapon material slot." );
		Check(
			report,
			discovered.All( material => !Path.HasExtension( material.SourceMaterialPath ) ),
			"Stored source material labels must not look like GameResource dependencies." );
		Equal(
			report,
			"weaponanim_preview_cache/0123456789abcdef",
			WeaponMaterialPipeline.LegalPreviewRelativeRootForTests(
				"/fixture/Assets/.weaponanim-cache/0123456789abcdef" ),
			"Preview materials must use a legal non-hidden asset namespace." );
		var originalRevision = WeaponMaterialPipeline.PreviewRevision( discovered );
		pistol.Textures[0].Sha256 = "changed-image-hash";
		var changedRevision = WeaponMaterialPipeline.PreviewRevision( discovered );
		Check(
			report,
			!originalRevision.Equals( changedRevision, StringComparison.Ordinal ),
			"A changed texture input must create a new immutable preview revision." );
		pistol.Textures[0].Sha256 = "";

		var document = ValidDocument();
		document.Source.Materials = discovered.ToList();
		var generated = WeaponMaterialPipeline.BuildOutputTextFiles(
			document,
			"weapons/test_weapon/viewmodel" );
		var material = generated["materials/test_weapon_h_k_p30l.vmat"];
		Check(
			report,
			material.Contains( "F_SPECULAR 1", StringComparison.Ordinal )
				&& material.Contains( "F_METALNESS_TEXTURE 1", StringComparison.Ordinal )
				&& material.Contains( "TextureMetalness", StringComparison.Ordinal )
				&& material.Contains(
					"test_weapon_h_k_p30l_metalness.png",
					StringComparison.Ordinal ),
			"Generated weapon VMATs must enable specular and mapped metalness." );
		Check(
			report,
			generated.Keys.Count( path => path.EndsWith(
				".vtex",
				StringComparison.OrdinalIgnoreCase ) ) == 0
				&& material.Contains(
					"test_weapon_h_k_p30l_color.png",
					StringComparison.Ordinal )
				&& material.Contains(
					"test_weapon_h_k_p30l_normal.png",
					StringComparison.Ordinal ),
			"VMATs must reference image inputs directly so S&box can build native generated VTEX resources." );
		document.Source.NeedsModelDocWrapper = true;
		pistol.PreviewMaterialPath =
			".weaponanim-cache/fixture/materials/h_k_p30l.vmat";
		Check(
			report,
			WeaponMaterialPipeline.RequiresPreviewRefresh( document ),
			"Legacy hidden preview material paths must force a safe material refresh." );
		foreach ( var binding in discovered.Where( binding => binding.HasUsableTextures ) )
		{
			binding.PreviewMaterialPath =
				$"weaponanim_preview_cache/fixture/revision/materials/{binding.OutputName}.vmat";
		}
		Check(
			report,
			!WeaponMaterialPipeline.RequiresPreviewRefresh( document ),
			"Legal compiled preview material paths must not refresh repeatedly." );
		var serializedDocument = Json.Serialize( document );
		Check(
			report,
			!serializedDocument.Contains(
				"PreviewMaterialPath",
				StringComparison.Ordinal )
				&& !serializedDocument.Contains(
					"H&K_P30L.vmat",
					StringComparison.OrdinalIgnoreCase ),
			"Transient preview VMATs and source slot extensions must stay out of .wepanim serialization." );

		var legacyMaterialDocument = WeaponAnimationDocument.CreateDefault();
		legacyMaterialDocument.Source.Materials =
		[
			new SourceMaterialBinding
			{
				SourceMaterialPath = "cartridge.vmat",
				Name = "cartridge",
				OutputName = "cartridge"
			}
		];
		var materialMigration = WeaponAnimationMigration.MigrateAndRepair(
			legacyMaterialDocument );
		Check(
			report,
			materialMigration.RepairedMaterialMetadata
				&& legacyMaterialDocument.Source.Materials[0].SourceMaterialPath
					.Equals( "cartridge", StringComparison.Ordinal ),
			"Opening an existing project must remove false VMAT dependencies from source slot metadata." );

		var recoveredCandidate = WeaponSourceImporter.SelectRecoveryCandidateForTests(
		[
			new(
				"/preview/newer-uncompiled/models/source_abc_textured.vmdl",
				new DateTime( 2026, 7, 28, 20, 0, 0, DateTimeKind.Utc ),
				false,
				true ),
			new(
				"/preview/legacy/models/source_abc_textured.vmdl",
				new DateTime( 2026, 7, 28, 19, 0, 0, DateTimeKind.Utc ),
				true,
				false ),
			new(
				"/preview/versioned/models/source_abc_textured.vmdl",
				new DateTime( 2026, 7, 28, 18, 0, 0, DateTimeKind.Utc ),
				true,
				true )
		] );
		Equal(
			report,
			"/preview/versioned/models/source_abc_textured.vmdl",
			recoveredCandidate,
			"Missing saved source wrappers must recover to a compiled immutable preview revision." );
		Check(
			report,
			!WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(
				"weaponanim_preview_cache/document/source.vmdl",
				"weaponanim_preview_cache/document/source.vmdl" )
				&& WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(
					"weaponanim_preview_cache/document/repaired.vmdl",
					"weaponanim_preview_cache/document/source.vmdl" ),
			"A failed source load must not rebuild the private scene every frame, "
				+ "but a repaired path must trigger one rebuild." );

		var remaps = WeaponMaterialPipeline.OutputRemaps(
			document,
			"weapons/test_weapon/viewmodel" );
		Equal(
			report,
			2,
			remaps.Count,
			"Final generation must preserve separate material-slot remaps." );
		var host = ModelDocWriter.WriteHost(
			"host_reference.dmx",
			[],
			"",
			["weapon_root"],
			new HostWeaponMesh(
				"source.fbx",
				"weapon_root",
				Transform.Zero,
				[],
				remaps ) );
		Check(
			report,
			host.Contains( "use_global_default = false", StringComparison.Ordinal )
				&& !host.Contains( "use_global_default = true", StringComparison.Ordinal )
				&& host.Contains( "from = \"H&K_P30L.vmat\"", StringComparison.Ordinal )
				&& host.Contains( "from = \"cartridge.vmat\"", StringComparison.Ordinal ),
			"Weapon ModelDocs must use per-slot remaps with global material override disabled." );
	}

	private static void TestRebase( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Rig.RootBone = "weapon_root";
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		var rootTrack = idle.EnsureTrack( "weapon_root" );
		WeaponAnimationMath.UpsertKey( rootTrack, 0, new Transform( new Vector3( 2, 0, 0 ) ) );
		var previous = new CalibrationSnapshot
		{
			PhysicalTransform = Transform.Zero,
			FramingTransform = Transform.Zero
		};
		document.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );
		CalibrationRebaser.RebaseAnimationData( document, previous );
		Near( report, 12, rootTrack.Keys[0].Position.x, 0.001f, "Root keys must retain their placement-relative offset." );
	}

	private static void TestDmxOutput( WeaponAnimatorSelfTestReport report )
	{
		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "weapon_root", "root", Vector3.Forward ) );
		var first = DmxWriter.WriteReference( skeleton );
		var second = DmxWriter.WriteReference( skeleton );

		Check( report, first.StartsWith( "<!-- dmx encoding keyvalues2 4 format model 22 -->" ), "Host reference must use ModelDoc's supported DMX model format." );
		Check( report, first.Contains( "\"name\" \"string\" \"weapon_root\"" ), "Host reference must include every skeleton bone." );
		Check(
			report,
			first.Contains( "\"element\" \"" + DmxJointIdForTest( 0 ) + "\"," ),
			"DMX element array entries must be comma-delimited." );
		var blendIndices = first[first.IndexOf( "\"blendindices$0\" \"int_array\"", StringComparison.Ordinal )..];
		Check(
			report,
			blendIndices.Contains( "\t\t\"1\",\n\t\t\"1\",\n\t\t\"1\"\n", StringComparison.Ordinal ),
			"The carrier mesh must reference every host bone so ModelDoc cannot cull the skeleton." );
		Check(
			report,
			first.Contains( "\t\t\t\t\"3\",\n\t\t\t\t\"4\",\n\t\t\t\t\"5\",\n\t\t\t\t\"-1\"\n", StringComparison.Ordinal ),
			"The carrier mesh must emit one triangle per host bone." );
		Check(
			report,
			first.Contains( "materials/tools/toolsinvisible.vmat", StringComparison.Ordinal ),
			"The bone-retention carrier must use an invisible material." );
		Check(
			report,
			first.Contains( "\"forwardParity\" \"int\" \"1\"", StringComparison.Ordinal )
				&& !first.Contains( "\"forwardParity\" \"int\" \"-2\"", StringComparison.Ordinal ),
			"Reference DMX must use the Source 2 Z-up axis parity expected by ModelDoc." );
		Equal( report, first, second, "DMX host references must be deterministic." );
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		var clip = document.EnsureClip( WeaponClipRole.Idle );
		clip.Duration = 1;
		clip.SampleRate = 30;
		var animation = DmxWriter.WriteAnimation( document, skeleton, clip );
		Check(
			report,
			animation.Contains( "\"DmeChannelsClip\"", StringComparison.Ordinal )
				&& animation.Contains( "\"DmeVector3LogLayer\"", StringComparison.Ordinal )
				&& animation.Contains( "\"DmeQuaternionLogLayer\"", StringComparison.Ordinal )
				&& animation.Contains( "\"DmeFloatLogLayer\"", StringComparison.Ordinal ),
			"DMX animation output must contain position, rotation, and scale channels." );
		Check(
			report,
			animation.Contains( "\"DmeJoint\"", StringComparison.Ordinal )
				&& !animation.Contains( "\"DmeDag\"", StringComparison.Ordinal ),
			"Animation skeleton entries must be Source 2 joints rather than generic DAG nodes." );
		Check(
			report,
			animation.Contains( "\"DmeTransformList\"", StringComparison.Ordinal )
				&& animation.Contains( "\"baseStates\" \"element_array\"", StringComparison.Ordinal ),
			"Animation DMX must include a bind transform list for ModelDoc sequence import." );
		Check(
			report,
			animation.Contains( "\"mode\" \"int\" \"1\"", StringComparison.Ordinal ),
			"Animation channels must use the Source 2 exporter channel mode." );
		Check(
			report,
			animation.Contains( "\"jointList\" \"element_array\"", StringComparison.Ordinal )
				&& animation.Contains(
					"\"element\" \"" + DmxAnimationJointIdForTest( clip, 0 ) + "\"",
					StringComparison.Ordinal ),
			"Animation DMX must register every animated joint with its model." );
		Check(
			report,
			animation.Contains( "\t\t\"1\"\n", StringComparison.Ordinal ),
			"A one-second animation must include its final sample time." );
		Check(
			report,
			!animation.Contains( "NaN", StringComparison.OrdinalIgnoreCase )
				&& !animation.Contains( "Infinity", StringComparison.OrdinalIgnoreCase ),
			"DMX animation output must contain finite transforms." );
		Check(
			report,
			animation.Contains( "\"forwardParity\" \"int\" \"1\"", StringComparison.Ordinal )
				&& !animation.Contains( "\"forwardParity\" \"int\" \"-2\"", StringComparison.Ordinal ),
			"Animation DMX must use the same Source 2 axis system as its host reference." );
		Equal(
			report,
			animation,
			DmxWriter.WriteAnimation( document, skeleton, clip ),
			"DMX animation output must be deterministic." );

		var scaledSkeleton = new HostSkeleton();
		scaledSkeleton.Add( new HostBone
		{
			Name = "root",
			BindModelTransform = Transform.Zero,
			BindLocalTransform = Transform.Zero,
			HasExplicitBindLocal = true
		} );
		scaledSkeleton.Add( new HostBone
		{
			Name = "weapon_root",
			ParentName = "root",
			BindModelTransform = new Transform(
				Vector3.Zero,
				Rotation.Identity,
				Vector3.One * 0.56f ),
			BindLocalTransform = new Transform(
				Vector3.Zero,
				Rotation.Identity,
				Vector3.One * 0.56f ),
			HasExplicitBindLocal = true,
			IsWeaponBone = true
		} );
		scaledSkeleton.Add( new HostBone
		{
			Name = "hammer",
			ParentName = "weapon_root",
			BindModelTransform = new Transform(
				new Vector3( 0, -5.75f, 0.2f ) * 0.56f ),
			BindLocalTransform = new Transform( new Vector3( 0, -5.75f, 0.2f ) ),
			HasExplicitBindLocal = true,
			IsWeaponBone = true
		} );
		var hammerTrack = clip.EnsureTrack( "hammer" );
		var hammerRotation = Rotation.FromPitch( 45 );
		WeaponAnimationMath.UpsertKey(
			hammerTrack,
			0,
			new Transform( new Vector3( 0, -5.75f, 0.2f ), hammerRotation ) );
		var scaledPose = AnimationPoseEvaluator.Evaluate(
			document,
			scaledSkeleton,
			clip,
			0 );
		var exportedPose = DmxWriter.BuildCompilerPoseLocals(
			scaledSkeleton,
			scaledPose.Local );
		var exportedRoot = exportedPose["weapon_root"];
		var exportedHammer = exportedPose["hammer"];
		Near(
			report,
			Vector3.One,
			exportedRoot.Scale,
			0.0001f,
			"Animation export must use ModelDoc's scale-one compiled bind space." );
		Near(
			report,
			new Vector3( 0, -5.75f, 0.2f ) * 0.56f,
			exportedHammer.Position,
			0.0001f,
			"Rotating a weapon child must use the physical scale-baked mesh pivot in compiled bind space." );
		Near(
			report,
			hammerRotation.Forward,
			exportedHammer.Rotation.Forward,
			0.0001f,
			"Rotating a weapon child must retain its authored local rotation in compiled bind space." );
		var scaledAnimation = DmxWriter.WriteAnimation(
			document,
			scaledSkeleton,
			clip );
		var scaledReference = DmxWriter.WriteReference( scaledSkeleton );
		Check(
			report,
			!scaledAnimation.Contains(
				"\"scale\" \"float\" \"0.56\"",
				StringComparison.Ordinal ),
			"Animation bind declarations must not reintroduce source scale after ModelDoc bakes it into the host." );
		Check(
			report,
			scaledReference.Contains(
				"\"position\" \"vector3\" \"0 -3.22 0.112\"",
				StringComparison.Ordinal )
				&& scaledAnimation.Contains(
					"\"position\" \"vector3\" \"0 -3.22 0.112\"",
					StringComparison.Ordinal ),
			"Reference and animation skeletons must share the scale-baked physical pivot of rotating weapon children." );

		document.Manifest.Files.Add( new GeneratedFileRecord
		{
			RelativePath = "generated_sequence.dmx"
		} );
		Check(
			report,
			!Json.Serialize( document ).Contains( "\"Manifest\"", StringComparison.Ordinal ),
			"The creative document must not serialize generated filenames as resource dependencies." );
		var wrapper = ModelDocWriter.WriteSourceWrapper( "weapon.fbx", "root" );
		Check(
			report,
			wrapper.Contains( "original_bone_name = \"root\"" )
				&& wrapper.Contains( "new_bone_name = \"weapon_root\"" ),
			"Source wrappers must normalize the selected weapon root." );
		var host = ModelDocWriter.WriteHost(
			"host_reference.dmx",
			[],
			"weapon.vanmgrph",
			skeleton.Bones.Select( bone => bone.Name ),
			new HostWeaponMesh(
				"weapon.fbx",
				"root",
				new Transform( Vector3.Zero, Rotation.Identity, Vector3.One * 0.6f ),
				[],
				[
					new HostMaterialRemap(
						"frame.vmat",
						"weapons/test/materials/frame.vmat" )
				] ),
			[
				new HostAttachment(
					"muzzle",
					"weapon_root",
					Vector3.Forward * 10,
					Rotation.Identity )
			] );
		Check(
			report,
			host.Contains( "target_bone = \"weapon_root\"", StringComparison.Ordinal )
				&& host.Contains( "do_not_discard = true", StringComparison.Ordinal )
				&& host.Contains( "filename = \"weapon.fbx\"", StringComparison.Ordinal )
				&& host.Contains( "import_scale = 0.6", StringComparison.Ordinal )
				&& host.Contains( "anim_graph_name = \"weapon.vanmgrph\"", StringComparison.Ordinal )
				&& host.Contains( "use_global_default = false", StringComparison.Ordinal )
				&& !host.Contains( "use_global_default = true", StringComparison.Ordinal )
				&& host.Contains( "from = \"frame.vmat\"", StringComparison.Ordinal )
				&& host.Contains( "from = \"materials/tools/toolsinvisible.vmat\"", StringComparison.Ordinal )
				&& host.Contains( "_class = \"Attachment\"", StringComparison.Ordinal )
				&& host.Contains( "name = \"muzzle\"", StringComparison.Ordinal ),
			"Host ModelDocs must preserve generated bones, safely handle imported materials, and contain the visible weapon, graph, and attachments." );
		var skeletonOnlyHost = ModelDocWriter.WriteHost(
			"host_reference.dmx",
			[],
			"",
			skeleton.Bones.Select( bone => bone.Name ) );
		Check(
			report,
			skeletonOnlyHost.Contains( "use_global_default = false", StringComparison.Ordinal ),
			"A skeleton-only host must retain the invisible carrier material without substitution." );
	}

	private static void TestFilteredSourceWrapper( WeaponAnimatorSelfTestReport report )
	{
		var wrapper = ModelDocWriter.WriteSourceWrapper(
			"weapons/test/source.fbx",
			"Armature",
			["foreign_arm", "foreign_camera"] );
		Check( report, wrapper.Contains( "_class = \"RenameBone\"" ), "A tool-owned source wrapper must normalize the root without modifying the original source." );
		Check( report, wrapper.Contains( "_class = \"RemoveBoneAndChildren\"" ), "A filtered source wrapper must remove excluded branch roots." );
		Check( report, wrapper.Contains( "\"foreign_arm\"" ) && wrapper.Contains( "\"foreign_camera\"" ), "Every excluded branch root must be emitted deterministically." );
		var vmdl = $"{ModelDocWriter.Header}\n{{ rootNode = {{ _class = \"RootNode\" children = [ ] }} }}";
		var adapted = ModelDocWriter.WriteVmdlSourceAdapter( vmdl, "root", ["foreign_arm"] );
		Check(
			report,
			adapted.Contains( "_class = \"ModelModifierList\"" )
				&& adapted.Contains( "original_bone_name = \"root\"" )
				&& adapted.Contains( "\"foreign_arm\"" ),
			"VMDL inputs must receive the same tool-owned root normalization and branch filtering." );
	}

	private static void TestGenerationSourceAdapters( WeaponAnimatorSelfTestReport report )
	{
		var source = $$"""
			{{ModelDocWriter.Header}}
			{
				rootNode =
				{
					_class = "RootNode"
					children =
					[
						{
							_class = "RenderMeshFile"
							filename = "receiver.fbx"
							import_translation = [ 2, 0, 0 ]
							import_rotation = [ 0, 0, 0 ]
							import_scale = 1
						},
						{
							_class = "RenderMeshFile"
							filename = "magazine.fbx"
							import_translation = [ 0, 2, 0 ]
							import_rotation = [ 0, 0, 0 ]
							import_scale = 1
						},
					]
				}
			}
			""";
		var adapted = ModelDocWriter.WriteVmdlSourceAdapter(
			source,
			"root",
			["foreign_arm"],
			new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, 0.5f ) );
		Check(
			report,
			Count( adapted, "import_scale = 0.5" ) == 2
				&& adapted.Contains( "import_translation = [ 11, 0, 0 ]", StringComparison.Ordinal )
				&& adapted.Contains( "import_translation = [ 10, 1, 0 ]", StringComparison.Ordinal )
				&& adapted.Contains( "original_bone_name = \"root\"", StringComparison.Ordinal )
				&& adapted.Contains( "\"foreign_arm\"", StringComparison.Ordinal ),
			"A VMDL adapter must apply calibration to every render mesh while preserving filtering." );

		var baseHost = ModelDocWriter.WriteHost(
			"reference.dmx",
			[],
			"",
			["weapon_root"],
			baseModelPath: "weapons/test/source_adapter.vmdl" );
		Check(
			report,
			baseHost.Contains(
				"base_model_name = \"weapons/test/source_adapter.vmdl\"",
				StringComparison.Ordinal ),
			"Generated hosts must be able to derive their visible mesh from a VMDL adapter." );

		var temporary = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-source-{Guid.NewGuid():N}.vmdl" );
		File.WriteAllText( temporary, source );
		try
		{
			var document = ValidDocument();
			document.Source.SourcePath = temporary;
			document.Source.CompiledModelPath = temporary;
			document.Calibration.PhysicalTransform =
				new Transform( Vector3.Zero, Rotation.Identity, 0.6f );
			var progress = new List<GenerationProgress>();
			var generated = AssetGenerationService.BuildFiles(
				document,
				HostSkeletonBuilder.Build( document, includeArmProfile: false ),
				"weapons/test_weapon/viewmodel",
				progress.Add );
			Check(
				report,
				generated.ContainsKey( "test_weapon_source_adapter.vmdl" )
					&& generated["test_weapon_vm.vmdl"].Contains(
						"base_model_name = \"weapons/test_weapon/viewmodel/test_weapon_source_adapter.vmdl\"",
						StringComparison.Ordinal ),
				"VMDL source projects must generate a persistent calibrated adapter." );
			Check(
				report,
				progress.Any( item =>
					item.Stage == "Sequences"
					&& item.Completed == 1
					&& item.Total == 1 ),
				"Generation must report deterministic per-sequence progress." );
			using var cancellation = new System.Threading.CancellationTokenSource();
			cancellation.Cancel();
			var cancelled = false;
			try
			{
				AssetGenerationService.BuildFiles(
					document,
					HostSkeletonBuilder.Build( document, includeArmProfile: false ),
					"weapons/test_weapon/viewmodel",
					cancellationToken: cancellation.Token );
			}
			catch ( OperationCanceledException )
			{
				cancelled = true;
			}
			Check(
				report,
				cancelled,
				"Generation must honor cancellation before assembling or replacing output files." );
			cancelled = false;
			try
			{
				DmxWriter.WriteAnimation(
					document,
					HostSkeletonBuilder.Build( document, includeArmProfile: false ),
					document.EnsureClip( WeaponClipRole.Idle ),
					cancellation.Token );
			}
			catch ( OperationCanceledException )
			{
				cancelled = true;
			}
			Check(
				report,
				cancelled,
				"DMX frame sampling must observe cancellation inside the worker-safe generation path." );
		}
		finally
		{
			File.Delete( temporary );
		}
	}

	private static string DmxJointIdForTest( int index )
	{
		var bytes = System.Security.Cryptography.SHA256.HashData(
			System.Text.Encoding.UTF8.GetBytes( $"SboxWeaponAnimator.DmxReference:joint:{index}" ) );
		return new Guid( bytes.AsSpan( 0, 16 ) ).ToString();
	}

	private static string DmxAnimationJointIdForTest(
		WeaponAnimationClip clip,
		int index )
	{
		var key =
			$"SboxWeaponAnimator.DmxReference:animation:{clip.Id}:joint:{index}";
		var bytes = System.Security.Cryptography.SHA256.HashData(
			System.Text.Encoding.UTF8.GetBytes( key ) );
		return new Guid( bytes.AsSpan( 0, 16 ) ).ToString();
	}

	private static void TestDeterministicOutput( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		var originalCulture = CultureInfo.CurrentCulture;
		try
		{
			CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( "fr-FR" );
				var graphFrench = AnimGraphWriter.Write( document, "weapons/test/host.vmdl" );
				var modelFrench = ModelDocWriter.WriteHost(
					"host_reference.dmx",
					[(idle, "idle.dmx")],
					"weapon.vanmgrph",
					["root", "weapon_root"] );
			var prefabFrench = PrefabWriter.Write( document, "host.vmdl" );

			CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( "en-US" );
			Equal( report, graphFrench, AnimGraphWriter.Write( document, "weapons/test/host.vmdl" ), "AnimGraph output must be culture-independent." );
			Equal(
					report,
					modelFrench,
					ModelDocWriter.WriteHost(
						"host_reference.dmx",
						[(idle, "idle.dmx")],
						"weapon.vanmgrph",
						["root", "weapon_root"] ),
				"ModelDoc output must be culture-independent." );
			Equal( report, prefabFrench, PrefabWriter.Write( document, "host.vmdl" ), "Prefab output must be culture-independent." );
			Equal( report, AnimGraphWriter.Id( "node:Root" ), AnimGraphWriter.Id( "node:Root" ), "Deterministic graph IDs must be stable." );
		}
		finally
		{
			CultureInfo.CurrentCulture = originalCulture;
		}
	}

	private static void TestAnimGraphTagsAndFallbacks( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		idle.Tags.Add( new AnimationTag
		{
			Name = "attack_discouraged",
			Kind = AnimationTagKind.Range,
			StartTime = 0.2f,
			EndTime = 0.6f
		} );
		var graph = AnimGraphWriter.Write( document, "host.vmdl" );
		Check( report, graph.Contains( "_class = \"CAnimTagSpan\"" ), "Authored tags must become sequence tag spans." );
		Check( report, graph.Contains( "m_fStartCycle = 0.2" ), "Tag start time must be normalized to sequence cycle." );
		Check(
			report,
			Count( graph, "m_sequenceName = \"idle\"" ) > 1,
			"Missing action clips must use Idle sequence fallbacks." );
		Check( report, graph.Contains( "m_name = \"b_attack\"" ), "Facepunch firearm parameters must be exposed." );
		Check( report, graph.Contains( "m_name = \"reload_increment\"" ), "Standard reload tags must be declared." );

		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		var generated = AssetGenerationService.BuildFiles(
			document,
			skeleton,
			"weapons/test_weapon/viewmodel" );
		var finalHost = generated["test_weapon_vm.vmdl"];
		var bootstrapHost = generated["test_weapon_vm_bootstrap.vmdl"];
		var generatedGraph = generated["test_weapon.vanmgrph"];
		Check(
			report,
			finalHost.Contains(
				"anim_graph_name = \"weapons/test_weapon/viewmodel/test_weapon.vanmgrph\"",
				StringComparison.Ordinal )
				&& bootstrapHost.Contains( "anim_graph_name = \"\"", StringComparison.Ordinal )
				&& generatedGraph.Contains(
					"m_previewModels = [ \"weapons/test_weapon/viewmodel/test_weapon_vm_bootstrap.vmdl\", ]",
					StringComparison.Ordinal ),
			"Generation must keep a permanent graph-free preview host while the final host always links its AnimGraph." );
		Check(
			report,
			generated.ContainsKey( "test_weapon_sequence_idle.dmx" )
				&& !generated.ContainsKey( "test_weapon_idle.dmx" )
				&& !generated.ContainsKey( "test_weapon_sequence_fire.dmx" ),
			"Generation must emit authored sequences only and leave missing action roles on Idle fallbacks." );

		var custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		custom.Name = "Mechanical Check";
		custom.Readiness = ClipReadiness.Draft;
		document.Clips.Add( custom );
		WeaponAnimationNames.RepairCustomSequenceNames( document );
		generated = AssetGenerationService.BuildFiles(
			document,
			skeleton,
			"weapons/test_weapon/viewmodel" );
		Check(
			report,
			generated.ContainsKey(
				$"test_weapon_sequence_{custom.GeneratedSequenceName}.dmx" ),
			"Authored custom clips must use their persisted readable sequence name." );
	}

	private static void TestPartVisibility( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		var part = new WeaponVisibilityPart
		{
			Name = "Spare Magazine",
			BoneId = "weapon_root",
			BoneName = "weapon_root",
			DefaultVisible = false
		};
		document.Rig.VisibilityParts.Add( part );
		var track = idle.EnsureVisibilityTrack( part.Id );
		var show = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, true );
		WeaponVisibilityEvaluator.UpsertKey( track, 0.8f, false );

		Check(
			report,
			!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.1f )
				&& WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f )
				&& !WeaponVisibilityEvaluator.Evaluate( part, idle, 0.9f ),
			"Visibility tracks must evaluate as stepped state changes from the configured default." );
		var replacement = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, false );
		Equal(
			report,
			show.Id,
			replacement.Id,
			"Keying visibility twice at one frame must update the existing key." );
		Check(
			report,
			!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f ),
			"A replaced visibility key must take effect immediately." );
		replacement.Visible = true;

		var spans = WeaponVisibilityEvaluator.BuildSpans( part, idle );
		Equal( report, 3, spans.Count, "Visibility export must cover the full clip with deterministic state spans." );
		Near( report, 0, spans[0].StartTime, 0.0001f, "The first visibility span must begin at clip start." );
		Near( report, idle.Duration, spans[^1].EndTime, 0.0001f, "The final visibility span must reach clip end." );

		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		var before = DmxWriter.WriteAnimation( document, skeleton, idle );
		var graph = AnimGraphWriter.Write( document, "host.vmdl" );
		var after = DmxWriter.WriteAnimation( document, skeleton, idle );
		Equal(
			report,
			before,
			after,
			"Visibility export must be deterministic and must not mutate authored transforms." );
		Check(
			report,
			before.Contains( "\"DmeFloatLogLayer\"", StringComparison.Ordinal )
				&& before.Contains( "\"0.0001\"", StringComparison.Ordinal )
				&& before.Contains( "-8192", StringComparison.Ordinal ),
			"Bone visibility must use native sequence scale and off-screen position channels." );
		Check(
			report,
			graph.Contains( WeaponVisibilityEvaluator.VisibleTag( part.Id ) )
				&& graph.Contains( WeaponVisibilityEvaluator.HiddenTag( part.Id ) ),
			"Generated AnimGraphs must declare both visibility states for every part." );

		var prefab = PrefabWriter.Write( document, "host.vmdl" );
		Check(
			report,
			!prefab.Contains( "WeaponPartVisibilityController", StringComparison.Ordinal )
				&& !prefab.Contains( "\"Name\": \"source_weapon\"", StringComparison.Ordinal )
				&& prefab.Contains( "\"Model\": \"host.vmdl\"", StringComparison.Ordinal )
				&& prefab.Contains( "\"GameLayer\": true", StringComparison.Ordinal )
				&& Count( prefab, "\"__type\": \"Sandbox.SkinnedModelRenderer\"" ) == 2
				&& prefab.Contains( "\"Name\": \"muzzle\"", StringComparison.Ordinal )
				&& prefab.Contains( "\"Name\": \"eject\"", StringComparison.Ordinal ),
			"Generated prefabs must use one visible host renderer plus bone-merged arms and explicit output anchors, with no custom controller." );
		document.Output.GenerateGraph = false;
		var graphFreePrefab = PrefabWriter.Write( document, "host.vmdl" );
		Check(
			report,
			graphFreePrefab.Contains( "\"UseAnimGraph\": false", StringComparison.Ordinal )
				&& !graphFreePrefab.Contains( "WeaponPartVisibilityController", StringComparison.Ordinal ),
			"Graph-free prefabs must remain standard and disable AnimGraph playback." );
		document.Output.GenerateGraph = true;

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetTimelineTime( 0.2f );
		controller.SelectKeys( [show.Id], false );
		controller.CopySelectedKeys();
		controller.SetTimelineTime( 0.5f );
		controller.PasteKeys();
		Check(
			report,
			idle.VisibilityTracks.Single( x => x.PartId == part.Id )
				.Keys.Any( x => MathF.Abs( x.Time - 0.5f ) <= 0.0001f ),
			"Visibility keys must participate in the shared copy and paste workflow." );

		part.RenderMode = VisibilityRenderMode.BodyGroup;
		part.BodyGroupName = "";
		var invalid = WeaponAnimationValidator.ValidateForGeneration( document );
		Check(
			report,
			invalid.Issues.Any( x => x.Code == "visibility.bodygroup_missing" )
				&& invalid.Issues.Any( x => x.Code == "visibility.bodygroup_export" ),
			"Generation validation must reject bodygroup visibility until it can be baked into a standard prefab." );
	}

	private static WeaponAnimationDocument ValidDocument()
	{
		var document = WeaponAnimationDocument.CreateDefault( "Test Weapon" );
		document.Source.SourcePath = "weapons/test/source.fbx";
		document.Source.CompiledModelPath = "weapons/test/source.vmdl";
		document.Source.Compiled = true;
		document.Source.PreviewHostCompiled = true;
		document.Rig.RootBone = "weapon_root";
		document.Rig.Bones.Add( new WeaponBoneDefinition
		{
			Id = "weapon_root",
			HierarchyPath = "weapon_root",
			Name = "weapon_root",
			OriginalName = "weapon_root",
			Classification = WeaponBoneClassification.WeaponRoot,
			Inclusion = WeaponBoneInclusion.Included,
			BindTransform = Transform.Zero,
			BindModelTransform = Transform.Zero,
			BindLocalTransform = Transform.Zero,
			HasSkinInfluence = true
		} );
		document.Rig.SourceSkeletonRootId = "weapon_root";
		document.Rig.WeaponSubtreeRootId = "weapon_root";
		document.Rig.ReviewRequired = false;
		document.Rig.FilteredPreviewConfirmed = true;
		document.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.RearBore, Vector3.Zero ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.FrontBore, Vector3.Forward ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Muzzle, new Vector3( 12, 0, 1 ) ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, -1, 2 ) ) );
		document.Calibration.Confirmed = true;
		document.Calibration.Snapshot = new CalibrationSnapshot();
		document.EnsureClip( WeaponClipRole.Idle ).Readiness = ClipReadiness.Ready;
		return document;
	}

	private static WeaponAnchor Anchor( AnchorKind kind, Vector3 position ) => new()
	{
		Name = kind.ToString(),
		Kind = kind,
		BoneName = "weapon_root",
		LocalPosition = position
	};

	private static WeaponBoneDefinition Definition(
		string name,
		string parent,
		WeaponBoneClassification classification,
		Vector3 modelPosition ) =>
		Definition( name, parent, classification, new Transform( modelPosition ) );

	private static WeaponBoneDefinition Definition(
		string name,
		string parent,
		WeaponBoneClassification classification,
		Transform modelTransform ) => new()
	{
		Name = name,
		ParentName = parent,
		OriginalName = name,
		OriginalParentName = parent,
		Classification = classification,
		Inclusion = WeaponBoneInclusion.Included,
		BindTransform = modelTransform,
		BindModelTransform = modelTransform,
		HasSkinInfluence = true
	};

	private static HostBone Bone( string name, string parent, Vector3 position ) => new()
	{
		Name = name,
		ParentName = parent,
		BindModelTransform = new Transform( position )
	};

	private static float RotationLength( Rotation value ) =>
		MathF.Sqrt( value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w );

	private static int Count( string value, string fragment )
	{
		var count = 0;
		var offset = 0;
		while ( (offset = value.IndexOf( fragment, offset, StringComparison.Ordinal )) >= 0 )
		{
			count++;
			offset += fragment.Length;
		}
		return count;
	}

	private static void Run(
		WeaponAnimatorSelfTestReport report,
		string name,
		Action<WeaponAnimatorSelfTestReport> test )
	{
		try
		{
			test( report );
		}
		catch ( Exception ex )
		{
			report.Failures.Add( $"{name}: threw {ex.GetType().Name}: {ex.Message}" );
		}
	}

	private static void Check(
		WeaponAnimatorSelfTestReport report,
		bool condition,
		string message )
	{
		if ( condition )
			report.Passed++;
		else
			report.Failures.Add( message );
	}

	private static void Equal<T>(
		WeaponAnimatorSelfTestReport report,
		T expected,
		T actual,
		string message )
	{
		Check(
			report,
			EqualityComparer<T>.Default.Equals( expected, actual ),
			$"{message} Expected '{expected}', got '{actual}'." );
	}

	private static void Near(
		WeaponAnimatorSelfTestReport report,
		float expected,
		float actual,
		float tolerance,
		string message )
	{
		Check(
			report,
			MathF.Abs( expected - actual ) <= tolerance,
			$"{message} Expected {expected}, got {actual}." );
	}

	private static void Near(
		WeaponAnimatorSelfTestReport report,
		Vector3 expected,
		Vector3 actual,
		float tolerance,
		string message )
	{
		Check(
			report,
			expected.Distance( actual ) <= tolerance,
			$"{message} Expected {expected}, got {actual}." );
	}
}
#nullable enable annotations

using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

[EditorForAssetType( "wepanim" )]
public sealed class WeaponAnimatorWindow : DockWindow, IAssetEditor
{
	private readonly WeaponAnimatorController _controller = new();
	private readonly WeaponSourceImporter _importer = new();
	private readonly AssetGenerationService _generator = new();
	private bool _generating;
	private CancellationTokenSource? _generationCancellation;
	private bool _closeAfterGenerationStops;
	private bool _refreshingMaterials;
	private Asset? _asset;
	private WeaponAnimationAsset? _resource;
	private Widget? _root;
	private WeaponAnimatorToolbar? _toolbar;
	private WeaponAnimatorViewport? _viewport;
	private ValidationStatusPanel? _statusPanel;
	private Splitter? _horizontalSplitter;
	private Splitter? _verticalSplitter;
	private Splitter? _animationRightSplitter;
	private Splitter? _animationOuterSplitter;
	private Button? _validationButton;
	private Button? _generateButton;
	private Button? _playButton;
	private bool _allowClose;
	private bool _rebaseOnConfirm;
	private bool _rebuilding;
	private WeaponAnimationMigrationResult? _migration;
	private bool _migrationBackupRequired;
	private bool _recoveryWritePending;
	private bool _closing;
	private int _recoveryRequestVersion;

	public bool CanOpenMultipleAssets => false;
	public void SelectMember( string memberName ) { }

	public WeaponAnimatorWindow()
	{
		DeleteOnClose = true;
		WindowTitle = "S&box Weapon Animator";
		Title = WindowTitle;
		Size = new Vector2( 1600, 940 );
		MinimumSize = new Vector2( 1200, 720 );
		StateCookie = "SboxWeaponAnimator.Window";
		SetWindowIcon( "animation" );

		_controller.DocumentChanged += OnDocumentChanged;
		_controller.DirtyChanged += OnDirtyChanged;
		_controller.PlaybackChanged += RefreshToolbarState;

		BuildMenuBar();
		BuildWorkspace();
		Show();
	}

	public void AssetOpen( Asset asset )
	{
		_asset = asset;
		_resource = asset?.LoadResource<WeaponAnimationAsset>() ?? new WeaponAnimationAsset();
		var document = _resource.Document ?? WeaponAnimationDocument.CreateDefault();
		var adoptedName = AdoptAssetFileName( document, asset );
		_migration = MigrateAndRepair( document );
		var sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
			document,
			out var sourceRecoveryMessage );
		_migrationBackupRequired = _migration.Changed;
		_controller.SetDocument( document );
		if ( _migration.Changed || sourceRecovered || adoptedName )
			_controller.ReplaceWithoutHistory( document, true );

		if ( document.ActiveStage == WeaponAnimatorStage.Animate
			&& document.Source.Compiled )
		{
			PreviewHostBuilder.Build( document );
		}

		BuildWorkspace();
		if ( !OfferRecovery() )
			OfferCachedImportRecovery();
		if ( sourceRecovered )
			_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );
		else if ( _migration.Changed )
			_statusPanel?.SetMessage( _migration.Summary, ValidationSeverity.Warning );
		RefreshTitle();
	}

	protected override bool OnClose()
	{
		SaveWorkspaceState();
		if ( _generating )
		{
			_closeAfterGenerationStops = true;
			_generationCancellation?.Cancel();
			_statusPanel?.SetMessage(
				"Cancelling asset generation before closing…",
				ValidationSeverity.Warning );
			return false;
		}
		if ( _allowClose || !_controller.IsDirty )
		{
			DestroyWorkspace();
			return true;
		}

		Dialog.AskConfirm(
			() =>
			{
				if ( Save() )
				CloseAfterPrompt();
			},
			() =>
			{
				Dialog.AskConfirm(
					// Discarding closes without saving, but the autosave snapshot is kept so the
					// work is still recoverable on the next open. Only Save clears it.
					() => CloseAfterPrompt( clearRecovery: false ),
					"Discard all unsaved changes to this Weapon Animation Project?",
					"Discard Changes",
					"Discard",
					"Cancel" );
			},
			"Save changes before closing this Weapon Animation Project?",
			"Unsaved Weapon Animation Project",
			"Save",
			"More Options" );
		return false;
	}

	[Shortcut( "editor.save", "Ctrl+S", ShortcutType.Window )]
	private void ShortcutSave() => Save();

	[Shortcut( "editor.undo", "Ctrl+Z", ShortcutType.Window )]
	private void ShortcutUndo() => _controller.Undo();

	[Shortcut( "editor.redo", "Ctrl+Y", ShortcutType.Window )]
	private void ShortcutRedo() => _controller.Redo();

	[Shortcut( "weaponanim.copykeys", "Ctrl+C", ShortcutType.Window )]
	private void ShortcutCopy() => _controller.CopySelectedKeys();

	[Shortcut( "weaponanim.pastekeys", "Ctrl+V", ShortcutType.Window )]
	private void ShortcutPaste() => _controller.PasteKeys();

	[Shortcut( "weaponanim.cutkeys", "Ctrl+X", ShortcutType.Window )]
	private void ShortcutCut() => _controller.CutSelectedKeys();

	[Shortcut( "weaponanim.key", "K", ShortcutType.Window )]
	private void ShortcutKey() => _controller.KeySelectedTransform();

	[Shortcut( "weaponanim.move", "W", ShortcutType.Window )]
	private void ShortcutMove()
	{
		if ( _viewport?.ConsumesFreeLookMovementShortcut == true )
			return;
		_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Move );
	}

	[Shortcut( "weaponanim.rotate", "E", ShortcutType.Window )]
	private void ShortcutRotate() =>
		_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Rotate );

	[Shortcut( "weaponanim.scale", "R", ShortcutType.Window )]
	private void ShortcutScale() =>
		_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Scale );

	[EditorEvent.Hotload]
	public void OnHotload()
	{
		HostSkeletonBuilder.ClearCache();
		SaveWorkspaceState();
		MenuBar.Clear();
		BuildMenuBar();
		var sourceRecovered = false;
		var sourceRecoveryMessage = "";
		_controller.Mutate(
			"Recover missing source preview",
			document => sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
				document,
				out sourceRecoveryMessage ) );
		if ( _controller.Document.Source.Compiled )
			PreviewHostBuilder.Build( _controller.Document );
		BuildWorkspace();
		if ( sourceRecovered )
			_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );
	}

	private void BuildMenuBar()
	{
		var file = MenuBar.AddMenu( "File" );
		file.AddOption( "New", "note_add", WeaponAnimatorLauncher.CreateNew );
		file.AddOption( "Open…", "folder_open", WeaponAnimatorLauncher.OpenExisting );
		file.AddSeparator();
		file.AddOption( "Save", "save", () => Save(), "editor.save" );
		file.AddOption( "Save As…", "save_as", SaveAs );
		file.AddOption( "Generate Assets", "build", GenerateAssets );
		file.AddSeparator();
		file.AddOption( "Close", "close", Close );

		var edit = MenuBar.AddMenu( "Edit" );
		edit.AddOption( "Undo", "undo", _controller.Undo, "editor.undo" );
		edit.AddOption( "Redo", "redo", _controller.Redo, "editor.redo" );
		edit.AddSeparator();
		edit.AddOption( "Cut Keys", "content_cut", _controller.CutSelectedKeys );
		edit.AddOption( "Copy Keys", "content_copy", _controller.CopySelectedKeys );
		edit.AddOption( "Paste Keys", "content_paste", _controller.PasteKeys );
		edit.AddOption( "Delete Keys", "delete", _controller.DeleteSelectedKeys );
		edit.AddSeparator();
		edit.AddOption( "Preferences…", "tune", OpenPreferences );

		var view = MenuBar.AddMenu( "View" );
		view.AddOption( "Calibrate", "straighten", RequestCalibrationStage );
		view.AddOption( "Animate", "animation", () => SwitchStage( WeaponAnimatorStage.Animate ) );
		view.AddSeparator();
		var guides = view.AddOption( "Toggle Guides", "aspect_ratio", () =>
			_controller.Mutate( "Viewport guides", d => d.Workspace.ShowGuides = !d.Workspace.ShowGuides ) );
		BindCheckedState( guides, () => _controller.Document.Workspace.ShowGuides );
		var skeleton = view.AddOption( "Toggle Skeleton", "accessibility_new", () =>
			_controller.Mutate( "Skeleton overlay", d => d.Workspace.ShowSkeleton = !d.Workspace.ShowSkeleton ) );
		BindCheckedState( skeleton, () => _controller.Document.Workspace.ShowSkeleton );
		var xray = view.AddOption( "X-Ray Skeleton", "visibility", () =>
			_controller.UpdateWorkspacePreference(
				"X-ray skeleton",
				workspace => workspace.XRaySkeleton = !workspace.XRaySkeleton ) );
		BindCheckedState( xray, () => _controller.Document.Workspace.XRaySkeleton );
		var boneOcclusion = view.AddOption( "Bone Occlusion", "gradient", () =>
			_controller.UpdateWorkspacePreference(
				"Bone occlusion",
				workspace => workspace.BoneOcclusionEnabled =
					!workspace.BoneOcclusionEnabled ) );
		BindCheckedState(
			boneOcclusion,
			() => _controller.Document.Workspace.BoneOcclusionEnabled );
		var ikBones = view.AddOption( "Show IK Bones", "polyline", () =>
			_controller.UpdateWorkspacePreference(
				"Show IK bones",
				workspace => workspace.ShowIkBones = !workspace.ShowIkBones ) );
		BindCheckedState( ikBones, () => _controller.Document.Workspace.ShowIkBones );
		var onionSkins = view.AddOption( "Toggle Onion Skins", "filter_none", () =>
			_controller.Mutate( "Onion skins", d => d.Workspace.ShowOnionSkins = !d.Workspace.ShowOnionSkins ) );
		BindCheckedState(
			onionSkins,
			() => _controller.Document.Workspace.ShowOnionSkins );
		var cameraPreview = view.AddOption( "Viewmodel Camera Preview", "videocam", () =>
			_controller.Mutate(
				"Preview camera",
				d => d.Workspace.FirstPersonPreview = !d.Workspace.FirstPersonPreview ) );
		BindCheckedState(
			cameraPreview,
			() => _controller.Document.Workspace.FirstPersonPreview );
		view.AddSeparator();
		view.AddOption( "Reset Workspace", "restart_alt", ResetWorkspace );

		var tools = MenuBar.AddMenu( "Tools" );
		tools.AddOption( "Validate", "rule", Validate );
		tools.AddOption( "Rebuild Preview Rig", "refresh", RebuildPreviewHost );
		tools.AddOption( "Reimport Source", "published_with_changes", ReimportSource );
		tools.AddOption( "Refresh Materials", "texture", RefreshMaterials );
		tools.AddOption( "Open Generated Folder", "folder", OpenGeneratedFolder );
	}

	private static void BindCheckedState( Option option, Func<bool> fetch )
	{
		option.Checkable = true;
		option.Checked = fetch();
		option.FetchCheckedState = fetch;
	}

	private void BuildWorkspace()
	{
		if ( _rebuilding )
			return;
		_rebuilding = true;
		SaveWorkspaceState();
		DestroyWorkspace();

		_root = new Widget( this );
		_root.SetStyles( "background-color: rgb(13,15,17); border: none;" );
		_root.Layout = Layout.Column();
		_root.Layout.Margin = 0;
		_root.Layout.Spacing = 4;

		_toolbar = new WeaponAnimatorToolbar( _root );
		BuildToolbar();
		_root.Layout.Add( _toolbar );

		_viewport = new WeaponAnimatorViewport( _controller );
		_viewport.StatusChanged += ( message ) => _statusPanel?.SetMessage( message );
		_viewport.LegacyIdleRepaired += () => _migrationBackupRequired = true;

		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
			BuildCalibrationLayout();
		else
			BuildAnimationLayout();

		Canvas = _root;
		_rebuilding = false;
		RefreshToolbarState();
	}

	private void BuildToolbar()
	{
		if ( _toolbar is null )
			return;
		_toolbar.Clear();
		_toolbar.AddLeft( "Save", "save", () => Save() );
		_generateButton = _toolbar.AddLeft( "Generate", "build", GenerateAssets, true );
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Animate )
		{
			_playButton = _toolbar.AddLeft( "Play", "play_arrow", TogglePlayback );
		}
		_toolbar.AddLeft( "Undo", "undo", _controller.Undo, overflowAtNarrowWidth: true );
		_toolbar.AddLeft( "Redo", "redo", _controller.Redo, overflowAtNarrowWidth: true );

		var calibrate = _toolbar.AddCenter(
			"1  Calibrate",
			"straighten",
			RequestCalibrationStage,
			_controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate );
		calibrate.IsToggle = true;
		calibrate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate;
		var animate = _toolbar.AddCenter(
			"2  Animate",
			"animation",
			() => SwitchStage( WeaponAnimatorStage.Animate ),
			_controller.Document.ActiveStage == WeaponAnimatorStage.Animate );
		animate.IsToggle = true;
		animate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Animate;

		_validationButton = _toolbar.AddRight( "Validate", "rule", Validate );
		RefreshGenerationButton();
		_toolbar.BalanceCenter();
	}

	private void BuildCalibrationLayout()
	{
		if ( _root is null || _viewport is null )
			return;

		var rigPanel = new RigAuditPanel( _controller );
		rigPanel.ImportRequested += ImportSource;
		rigPanel.RigReviewConfirmed += RebuildPreviewHost;

		var inspector = new CalibrationInspectorPanel( _controller );
		inspector.PickRequested += _viewport.SetPickMode;
		inspector.AutoAlignRequested += AutoAlign;
		inspector.ConfirmRequested += ConfirmCalibration;
		inspector.RebuildPreviewRequested += RebuildPreviewHost;
		inspector.SetModelDimensions( _viewport.ModelDimensions );
		_viewport.ModelDimensionsChanged += inspector.SetModelDimensions;

		_statusPanel = new ValidationStatusPanel();
		var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
		_statusPanel.SetReport( report );

		var left = new PanelChrome( "RIG AUDIT", "account_tree", rigPanel );
		var center = new PanelChrome( "3D CALIBRATION", "view_in_ar", _viewport );
		var right = new PanelChrome( "CALIBRATION", "tune", inspector );
		var bottom = new PanelChrome( "VALIDATION + IMPORT", "fact_check", _statusPanel );
		left.MinimumSize = new Vector2( 260, 200 );
		left.MaximumSize = new Vector2( 520, 10000 );
		right.MinimumSize = new Vector2( 310, 200 );
		right.MaximumSize = new Vector2( 560, 10000 );
		center.MinimumSize = new Vector2( 420, 240 );
		bottom.MinimumSize = new Vector2( 200, 55 );
		bottom.MaximumSize = new Vector2( 10000, 190 );
		BuildSplitLayout( left, center, right, bottom, true );
	}

	private void BuildAnimationLayout()
	{
		if ( _root is null || _viewport is null )
			return;

		var rigBrowser = new RigBrowserPanel( _controller );
		var inspector = new SelectedControlInspectorPanel( _controller );
		var clips = new ClipRackPanel(
			_controller,
			showClipHeader: false );
		var timeline = new AnimationTimelinePanel( _controller );
		clips.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );
		inspector.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );
		_statusPanel = new ValidationStatusPanel();
		_statusPanel.SetReport( WeaponAnimationValidator.ValidateForGeneration( _controller.Document ) );

		var left = new PanelChrome( "RIG BROWSER", "account_tree", rigBrowser );
		var center = new PanelChrome( "3D ANIMATION", "view_in_ar", _viewport );
		var right = new PanelChrome( "SELECTED CONTROL", "tune", inspector );
		var clipRack = new PanelChrome( "CLIP RACK", "video_library", clips );
		var bottom = new PanelChrome( "DOPE SHEET · CURVES · TAGS", "timeline", timeline );
		left.MinimumSize = new Vector2( 330, 240 );
		left.MaximumSize = new Vector2( 540, 10000 );
		right.MinimumSize = new Vector2( 370, 240 );
		right.MaximumSize = new Vector2( 600, 10000 );
		clipRack.MinimumSize = new Vector2( 370, 260 );
		clipRack.MaximumSize = new Vector2( 600, 10000 );
		center.MinimumSize = new Vector2( 420, 260 );
		bottom.MinimumSize = new Vector2( 300, 220 );
		bottom.MaximumSize = new Vector2( 10000, 520 );
		BuildAnimationSplitLayout( left, center, right, clipRack, bottom );
	}

	private void BuildAnimationSplitLayout(
		Widget left,
		Widget center,
		Widget inspector,
		Widget clips,
		Widget timeline )
	{
		if ( _root is null )
			return;

		_verticalSplitter = new Splitter( _root )
		{
			IsVertical = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_horizontalSplitter = new Splitter( _root )
		{
			IsHorizontal = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_horizontalSplitter.AddWidget( left );
		_horizontalSplitter.AddWidget( center );
		_horizontalSplitter.SetStretch( 0, 0 );
		_horizontalSplitter.SetStretch( 1, 1 );
		_horizontalSplitter.SetCollapsible( 0, false );
		_horizontalSplitter.SetCollapsible( 1, false );

		_verticalSplitter.AddWidget( _horizontalSplitter );
		_verticalSplitter.AddWidget( timeline );
		_verticalSplitter.SetStretch( 0, 1 );
		_verticalSplitter.SetStretch( 1, 0 );
		_verticalSplitter.SetCollapsible( 0, false );
		_verticalSplitter.SetCollapsible( 1, false );

		_animationRightSplitter = new Splitter( _root )
		{
			IsVertical = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_animationRightSplitter.AddWidget( inspector );
		_animationRightSplitter.AddWidget( clips );
		_animationRightSplitter.SetStretch( 0, 1 );
		_animationRightSplitter.SetStretch( 1, 1 );
		_animationRightSplitter.SetCollapsible( 0, false );
		_animationRightSplitter.SetCollapsible( 1, false );

		_animationOuterSplitter = new Splitter( _root )
		{
			IsHorizontal = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_animationOuterSplitter.AddWidget( _verticalSplitter );
		_animationOuterSplitter.AddWidget( _animationRightSplitter );
		_animationOuterSplitter.SetStretch( 0, 1 );
		_animationOuterSplitter.SetStretch( 1, 0 );
		_animationOuterSplitter.SetCollapsible( 0, false );
		_animationOuterSplitter.SetCollapsible( 1, false );
		_root.Layout.Add( _animationOuterSplitter, 1 );

		var workspace = _controller.Document.Workspace;
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationMainSplitterState ) )
			_horizontalSplitter.RestoreState( workspace.AnimationMainSplitterState );
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationVerticalSplitterState ) )
			_verticalSplitter.RestoreState( workspace.AnimationVerticalSplitterState );
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationRightSplitterState ) )
			_animationRightSplitter.RestoreState( workspace.AnimationRightSplitterState );
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationOuterSplitterState ) )
			_animationOuterSplitter.RestoreState( workspace.AnimationOuterSplitterState );
	}

	private void BuildSplitLayout(
		Widget left,
		Widget center,
		Widget right,
		Widget bottom,
		bool calibration )
	{
		if ( _root is null )
			return;
		_horizontalSplitter = new Splitter( _root )
		{
			IsHorizontal = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_horizontalSplitter.AddWidget( left );
		_horizontalSplitter.AddWidget( center );
		_horizontalSplitter.AddWidget( right );
		_horizontalSplitter.SetStretch( 0, 0 );
		_horizontalSplitter.SetStretch( 1, 1 );
		_horizontalSplitter.SetStretch( 2, 0 );
		_horizontalSplitter.SetCollapsible( 0, false );
		_horizontalSplitter.SetCollapsible( 1, false );
		_horizontalSplitter.SetCollapsible( 2, false );

		_verticalSplitter = new Splitter( _root )
		{
			IsVertical = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_verticalSplitter.AddWidget( _horizontalSplitter );
		_verticalSplitter.AddWidget( bottom );
		_verticalSplitter.SetStretch( 0, 1 );
		_verticalSplitter.SetStretch( 1, 0 );
		_verticalSplitter.SetCollapsible( 0, false );
		_verticalSplitter.SetCollapsible( 1, false );
		_root.Layout.Add( _verticalSplitter, 1 );

		var workspace = _controller.Document.Workspace;
		var horizontalState = calibration
			? workspace.CalibrationSplitterState
			: workspace.AnimationSplitterState;
		var verticalState = calibration
			? workspace.CalibrationVerticalSplitterState
			: workspace.AnimationVerticalSplitterState;
		if ( !string.IsNullOrWhiteSpace( horizontalState ) )
			_horizontalSplitter.RestoreState( horizontalState );
		if ( !string.IsNullOrWhiteSpace( verticalState ) )
			_verticalSplitter.RestoreState( verticalState );
	}

	private void SaveWorkspaceState()
	{
		if ( _horizontalSplitter is null || _verticalSplitter is null )
			return;
		var workspace = _controller.Document.Workspace;
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
		{
			workspace.CalibrationSplitterState = _horizontalSplitter.SaveState();
			workspace.CalibrationVerticalSplitterState = _verticalSplitter.SaveState();
		}
		else
		{
			workspace.AnimationMainSplitterState = _horizontalSplitter.SaveState();
			workspace.AnimationVerticalSplitterState = _verticalSplitter.SaveState();
			if ( _animationRightSplitter is not null )
				workspace.AnimationRightSplitterState = _animationRightSplitter.SaveState();
			if ( _animationOuterSplitter is not null )
				workspace.AnimationOuterSplitterState = _animationOuterSplitter.SaveState();
		}
	}

	private void DestroyWorkspace()
	{
		// Destroy the private scene synchronously before replacing its widget tree.
		_viewport?.ReleasePreviewScene();
		if ( _root.IsValid() )
			_root!.Destroy();
		_root = null;
		_toolbar = null;
		_viewport = null;
		_statusPanel = null;
		_horizontalSplitter = null;
		_verticalSplitter = null;
		_animationRightSplitter = null;
		_animationOuterSplitter = null;
		_validationButton = null;
		_playButton = null;
	}

	private void ImportSource()
	{
		var dialog = new FileDialog( this )
		{
			Title = "Import Rigged Weapon",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" )
		};
		dialog.SetModeOpen();
		dialog.SetFindExistingFile();
		dialog.SetNameFilter( "Rigged Models (*.fbx *.smd *.dmx *.vmdl)" );
		if ( !dialog.Execute() )
			return;

		SourceImportResult? import = null;
		PreviewHostResult? host = null;
		_controller.Mutate( "Import source weapon", document =>
		{
			import = _importer.Import( document, dialog.SelectedFile );
			if ( import.Success )
				host = PreviewHostBuilder.Build( document );
		} );

		_statusPanel?.SetMessage(
			$"{import?.Message} {host?.Message}",
			import?.Success == true && host?.Success == true
				? ValidationSeverity.Info
				: ValidationSeverity.Error );
		_viewport?.RebuildPreview();
	}

	private void ReimportSource()
	{
		var source = string.IsNullOrWhiteSpace( _controller.Document.Source.OriginalSourcePath )
			? _controller.Document.Source.SourcePath
			: _controller.Document.Source.OriginalSourcePath;
		if ( string.IsNullOrWhiteSpace( source ) )
		{
			ImportSource();
			return;
		}

		SourceImportResult? result = null;
		_controller.Mutate( "Reimport source weapon", document =>
		{
			result = _importer.Import( document, source );
			if ( result.Success )
				PreviewHostBuilder.Build( document );
		} );
		_statusPanel?.SetMessage(
			result?.Message ?? "Reimport failed.",
			result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
		_viewport?.RebuildPreview();
	}

	private async void RefreshMaterials()
	{
		if ( _refreshingMaterials || _generating )
		{
			_statusPanel?.SetMessage(
				"Material refresh or generation is already in progress.",
				ValidationSeverity.Warning );
			return;
		}

		_refreshingMaterials = true;
		Log.Info( "[Weapon Animator] manual material refresh requested." );
		try
		{
			_statusPanel?.SetMessage(
				"Discovering and compiling source materials…",
				ValidationSeverity.Info );
			var result = await RefreshMaterialsCoreAsync( "Refresh source materials" );
			_statusPanel?.SetMessage(
				result.Message,
				result.Success
					? ValidationSeverity.Info
					: ValidationSeverity.Error );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] material refresh threw: {ex}" );
			_statusPanel?.SetMessage(
				$"Material refresh failed: {ex.Message}",
				ValidationSeverity.Error );
		}
		finally
		{
			_refreshingMaterials = false;
			RefreshToolbarState();
		}
	}

	private async System.Threading.Tasks.Task<SourceImportResult> RefreshMaterialsCoreAsync(
		string historyDescription )
	{
		var recovered = false;
		var recoveryMessage = "";
		_controller.Mutate(
			"Recover missing source preview",
			document => recovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
				document,
				out recoveryMessage ) );
		if ( recovered )
		{
			_viewport?.RebuildPreview();
			_statusPanel?.SetMessage( recoveryMessage, ValidationSeverity.Warning );
		}

		var documentId = _controller.Document.DocumentId;
		var sourceHash = _controller.Document.Source.SourceHash;
		var result = await _importer.RefreshMaterialsAsync( _controller.Document );
		if ( !result.Success )
			return result;

		if ( _controller.Document.DocumentId != documentId
			|| !string.Equals(
				_controller.Document.Source.SourceHash,
				sourceHash,
				StringComparison.OrdinalIgnoreCase ) )
		{
			return new SourceImportResult
			{
				Success = false,
				Message = "The open document or source changed while materials were compiling; "
					+ "the candidate preview was not applied."
			};
		}

		_controller.Mutate(
			historyDescription,
			document => WeaponSourceImporter.ApplyMaterialRefresh( document, result ) );
		_viewport?.RebuildPreview();
		WeaponSourceImporter.CleanupLegacyMaterialPreview( _controller.Document );
		return result;
	}

	private void AutoAlign()
	{
		var document = _controller.Document;
		var grip = document.Calibration.GetAnchor( AnchorKind.Grip );
		var rear = document.Calibration.GetAnchor( AnchorKind.RearBore );
		var front = document.Calibration.GetAnchor( AnchorKind.FrontBore );
		if ( grip is null || rear is null || front is null )
		{
			_statusPanel?.SetMessage(
				"Set the primary grip and both optional alignment markers before running Auto-align.",
				ValidationSeverity.Error );
			return;
		}

		if ( !WeaponAnimationMath.TryCalculateAlignment(
			grip.LocalPosition,
			rear.LocalPosition,
			front.LocalPosition,
			document.Calibration.UpAxis,
			document.Calibration.UniformScale,
			new Vector3( 12, -3, -2 ),
			out var alignment ) )
		{
			_statusPanel?.SetMessage( "The selected anchors cannot produce a finite alignment.", ValidationSeverity.Error );
			return;
		}

		_controller.Mutate( "Auto-align weapon", d =>
		{
			d.Calibration.PhysicalTransform = alignment.PhysicalTransform;
			var rearWorld = alignment.PhysicalTransform.PointToWorld( rear.LocalPosition );
			var correctionWorld = new Vector3( 0, -rearWorld.y, -rearWorld.z );
			var correctionLocal = alignment.PhysicalTransform.PointToLocal(
				alignment.PhysicalTransform.Position + correctionWorld );
			d.Calibration.FramingTransform = d.Calibration.FramingTransform.WithPosition( correctionLocal );
			d.Calibration.Confirmed = false;
		} );

		_statusPanel?.SetMessage(
			alignment.BoreMayBeReversed
				? "Aligned, but the bore points appear reversed. Swap rear and front if the muzzle faces away from +X."
				: "Grip placed at the canonical hand origin; bore aligned to +X and projected through the crosshair.",
			alignment.BoreMayBeReversed ? ValidationSeverity.Warning : ValidationSeverity.Info );
	}

	private void ConfirmCalibration()
	{
		PreviewHostResult? hostResult = null;
		_controller.Mutate( "Build calibrated preview host", document =>
			hostResult = PreviewHostBuilder.Build( document ) );
		var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
		if ( !report.IsValid || hostResult?.Success != true )
		{
			_statusPanel?.SetReport( report, hostResult?.Message ?? "" );
			return;
		}

		var previous = _controller.Document.Calibration.Snapshot;
		_controller.Mutate( "Confirm calibration", document =>
		{
			if ( _rebaseOnConfirm && previous is not null )
				CalibrationRebaser.RebaseAnimationData( document, previous );

			var calibration = document.Calibration;
			calibration.Revision++;
			calibration.Confirmed = true;
			calibration.Snapshot = new CalibrationSnapshot
			{
				Revision = calibration.Revision,
				SourceHash = document.Source.SourceHash,
				RigHash = document.Rig.ProfileHash,
				UniformScale = calibration.UniformScale,
				PhysicalTransform = calibration.PhysicalTransform,
				FramingTransform = calibration.FramingTransform,
				Anchors = Json.Deserialize<System.Collections.Generic.List<WeaponAnchor>>(
					Json.Serialize( calibration.Anchors ) ) ?? [],
				ConfirmedUtc = DateTime.UtcNow
			};
			if ( previous is null )
				CalibrationBindingSeeder.SeedDefaultPrimaryHand( document );

			var idle = document.EnsureClip( WeaponClipRole.Idle );
			if ( idle.Tracks.Count == 0 || idle.IsBindPoseSeed )
			{
				var skeleton = HostSkeletonBuilder.BuildCached( document );
				IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
			}
			idle.Readiness = ClipReadiness.Ready;
			document.Workspace.SelectedClipId = idle.Id;
			document.ActiveStage = WeaponAnimatorStage.Animate;
		} );
		_rebaseOnConfirm = false;
		BuildWorkspace();
	}

	private void RequestCalibrationStage()
	{
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
			return;
		var hasAnimation = _controller.Document.Clips.Any( x =>
			x.Tracks.Count > 0 && x.Role != WeaponClipRole.Idle );
		if ( !hasAnimation )
		{
			SwitchStage( WeaponAnimatorStage.Calibrate );
			return;
		}

		Dialog.AskConfirm(
			() =>
			{
				_rebaseOnConfirm = true;
				SwitchStage( WeaponAnimatorStage.Calibrate );
			},
			() =>
			{
				Dialog.AskConfirm(
					() =>
					{
						_controller.Mutate(
							"Discard animation for recalibration",
							CalibrationRebaser.DiscardAnimationData );
						_rebaseOnConfirm = false;
						SwitchStage( WeaponAnimatorStage.Calibrate );
					},
					"Discard all authored animation and binding data before recalibrating?",
					"Discard Animation Data",
					"Discard",
					"Cancel" );
			},
			"Rebase bindings, controls, and animation roots onto the new calibration when it is confirmed?",
			"Return to Calibration",
			"Rebase",
			"Other Options" );
	}

	private void SwitchStage( WeaponAnimatorStage stage )
	{
		if ( stage == _controller.Document.ActiveStage )
			return;
		if ( stage == WeaponAnimatorStage.Animate )
		{
			var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
			if ( !_controller.Document.Calibration.Confirmed || !report.IsValid )
			{
				_statusPanel?.SetMessage(
					"Confirm a valid calibration before entering Animate.",
					ValidationSeverity.Error );
				return;
			}
		}

		SaveWorkspaceState();
		_controller.Mutate( $"Switch to {stage}", d => d.ActiveStage = stage );
		BuildWorkspace();
	}

	private bool Save()
	{
		if ( _asset is null || _resource is null )
		{
			SaveAs();
			return _asset is not null;
		}

		SaveWorkspaceState();
		if ( _migrationBackupRequired )
		{
			try
			{
				WeaponAnimationMigration.CreateBackup(
					_asset.AbsolutePath,
					_migration?.SourceSchemaVersion ?? 2 );
			}
			catch ( Exception ex )
			{
				_statusPanel?.SetMessage(
					$"Migration backup failed; the project was not saved: {ex.Message}",
					ValidationSeverity.Error );
				return false;
			}
		}

		_resource.Document = _controller.Document;
		if ( !_asset.SaveToDisk( _resource ) )
		{
			_statusPanel?.SetMessage( "The .wepanim asset could not be saved.", ValidationSeverity.Error );
			return false;
		}

		_controller.MarkSaved();
		_migrationBackupRequired = false;
		RecoveryService.Clear( _controller.Document.DocumentId );
		_statusPanel?.SetMessage( $"Saved {_asset.Path}." );
		RefreshTitle();
		return true;
	}

	private void SaveAs()
	{
		var dialog = new FileDialog( this )
		{
			Title = "Save Weapon Animation Project As",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" ),
			DefaultSuffix = "wepanim"
		};
		dialog.SetModeSave();
		dialog.SetFindFile();
		dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
		if ( !dialog.Execute() )
			return;

		var path = Path.ChangeExtension( dialog.SelectedFile, ".wepanim" );
		var asset = AssetSystem.CreateResource( "wepanim", path );
		if ( asset is null )
		{
			_statusPanel?.SetMessage( "Could not create the new .wepanim asset.", ValidationSeverity.Error );
			return;
		}

		_asset = asset;
		// Saving under a new filename renames the project, so the generated output follows it.
		AdoptAssetFileName( _controller.Document, _asset );
		_resource = new WeaponAnimationAsset { Document = _controller.Document };
		if ( !_asset.SaveToDisk( _resource ) )
		{
			_statusPanel?.SetMessage( "Save As failed.", ValidationSeverity.Error );
			return;
		}
		_controller.MarkSaved();
		_migrationBackupRequired = false;
		RecoveryService.Clear( _controller.Document.DocumentId );
		RefreshTitle();
	}

	private async void GenerateAssets()
	{
		// Compiling waits on the asset system across frames, so keep a second press from
		// starting a competing run over the same output files.
		if ( _generating )
		{
			_generationCancellation?.Cancel();
			_statusPanel?.SetMessage(
				"Cancelling asset generation safely…",
				ValidationSeverity.Warning );
			return;
		}

		_generating = true;
		_generationCancellation = new CancellationTokenSource();
		RefreshGenerationButton();
		Log.Info( "[Weapon Animator] asset generation requested." );
		try
		{
			if ( WeaponMaterialPipeline.RequiresPreviewRefresh( _controller.Document ) )
			{
				var materialImport = await RefreshMaterialsCoreAsync(
					"Discover source materials" );
				if ( !materialImport.Success )
				{
					_statusPanel?.SetMessage(
						materialImport.Message,
						ValidationSeverity.Error );
					return;
				}
			}

			_statusPanel?.SetMessage( "Generating and compiling assets…", ValidationSeverity.Info );
			var result = await _generator.GenerateAsync(
				_controller.Document,
				progress =>
				{
					var count = progress.Total > 0
						? $" {progress.Completed}/{progress.Total}"
						: "";
					_statusPanel?.SetMessage(
						$"{progress.Stage}{count} — {progress.Detail}",
						ValidationSeverity.Info );
				},
				_generationCancellation.Token );
			if ( result.Success )
			{
				_statusPanel?.SetMessage(
					$"Generated and reloaded {result.GeneratedFiles.Count} files in {result.OutputFolder}.",
					ValidationSeverity.Info );
				Save();
			}
			else if ( result.Cancelled )
			{
				_statusPanel?.SetMessage(
					"Asset generation cancelled; previous owned outputs were restored.",
					ValidationSeverity.Warning );
			}
			else
			{
				var message = string.Join(
					"  ·  ",
					result.Diagnostics.Where( x => x.Severity == ValidationSeverity.Error )
						.Select( x => x.Message )
						.Take( 4 ) );
				_statusPanel?.SetMessage(
					string.IsNullOrWhiteSpace( message ) ? "Generation failed validation." : message,
					ValidationSeverity.Error );
			}
		}
		catch ( OperationCanceledException )
		{
			_statusPanel?.SetMessage(
				"Asset generation cancelled before outputs were changed.",
				ValidationSeverity.Warning );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] generation threw: {ex}" );
			_statusPanel?.SetMessage( $"Generation failed: {ex.Message}", ValidationSeverity.Error );
		}
		finally
		{
			_generating = false;
			_generationCancellation?.Dispose();
			_generationCancellation = null;
			RefreshGenerationButton();
			RefreshToolbarState();
			if ( _closeAfterGenerationStops )
			{
				_closeAfterGenerationStops = false;
				Close();
			}
		}
	}

	private void RefreshGenerationButton()
	{
		if ( _generateButton is null )
			return;

		_generateButton.Text = _generating ? "Cancel" : "Generate";
		_generateButton.Icon = _generating ? "stop" : "build";
		_generateButton.Tint = _generating
			? WeaponAnimatorTheme.Coral * 0.58f
			: WeaponAnimatorTheme.Cyan * 0.72f;
		if ( _generateButton is WeaponAnimatorButton button )
			button.FitToContent( true );
		_toolbar?.BalanceCenter();
	}

	private void Validate()
	{
		var report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate
			? WeaponAnimationValidator.ValidateCalibration( _controller.Document )
			: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );
		_statusPanel?.SetReport( report );
		RefreshToolbarState();
	}

	private void RebuildPreviewHost()
	{
		PreviewHostResult? result = null;
		_controller.Mutate( "Rebuild preview host", document =>
			result = PreviewHostBuilder.Build( document ) );
		_statusPanel?.SetMessage(
			result?.Message ?? "Preview host rebuild failed.",
			result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
		_viewport?.RebuildPreview();
	}

	private void OpenGeneratedFolder()
	{
		try
		{
			var path = AssetGenerationService.GetOutputFolder( _controller.Document );
			if ( Directory.Exists( path ) )
				EditorUtility.OpenFolder( path );
			else
				_statusPanel?.SetMessage( "Generate assets before opening the output folder.", ValidationSeverity.Warning );
		}
		catch ( Exception ex )
		{
			_statusPanel?.SetMessage(
				$"Could not resolve the output folder: {ex.Message}",
				ValidationSeverity.Error );
		}
	}

	private void TogglePlayback()
	{
		_controller.TogglePlayback();
	}

	private void ResetWorkspace()
	{
		_controller.Mutate( "Reset workspace", document =>
		{
			var state = document.Workspace;
			state.CameraFocus = Vector3.Zero;
			state.CameraAngles = new Angles( 12, 180, 0 );
			state.CameraDistance = 48;
			state.FreeLookCamera = false;
			state.CameraPosition = Vector3.Zero;
			state.CameraMoveSpeed = 1;
			state.FullBrightViewport = false;
			state.CalibrationSplitterState = "";
			state.CalibrationVerticalSplitterState = "";
			state.AnimationSplitterState = "";
			state.AnimationVerticalSplitterState = "";
			state.AnimationTimelineSplitterState = "";
			state.AnimationRightSplitterState = "";
			state.AnimationMainSplitterState = "";
			state.AnimationOuterSplitterState = "";
			state.TimelineViews.Clear();
			state.CurveViews.Clear();
		} );
		BuildWorkspace();
		_viewport?.FitCamera();
	}

	private void OpenPreferences()
	{
		new WeaponAnimatorPreferencesWindow( _controller ).Show();
	}

	private void OnDocumentChanged()
	{
		if ( _controller.IsDirty )
			QueueRecoveryWrite();
		RefreshTitle();
		RefreshToolbarState();
	}

	private void OnDirtyChanged()
	{
		if ( _controller.IsDirty )
			QueueRecoveryWrite();
		RefreshTitle();
	}

	private void QueueRecoveryWrite()
	{
		if ( _closing || !_controller.IsDirty )
			return;

		_recoveryRequestVersion++;
		if ( _recoveryWritePending )
			return;

		_recoveryWritePending = true;
		_ = WriteRecoveryAfterQuietPeriodAsync();
	}

	private async Task WriteRecoveryAfterQuietPeriodAsync()
	{
		try
		{
			while ( !_closing && _controller.IsDirty )
			{
				var requestedVersion = _recoveryRequestVersion;
				await Task.Delay( 750 );
				if ( requestedVersion != _recoveryRequestVersion )
					continue;

				// Re-check after the wait. Saving inside the quiet period clears the recovery
				// file, and writing it back would make the next open offer to restore a snapshot
				// of an already-saved project.
				if ( _closing || !_controller.IsDirty )
					return;

				// Serialize on the main thread: the continuation above can resume on a worker,
				// and the document may be mutated while it is being written.
				await GameTask.MainThread();
				if ( !_closing && _controller.IsDirty )
					RecoveryService.Write( _controller.Document );
				return;
			}
		}
		finally
		{
			_recoveryWritePending = false;
		}
	}

	/// <summary>
	/// The .wepanim filename is the project's identity: generated folders and asset names follow it.
	/// This has to run on every open rather than only for new documents, because
	/// <c>WeaponAnimationAsset.Document</c> is initialised with <c>CreateDefault()</c> — it is never
	/// null, so an asset created outside the New Project flow always arrives carrying the
	/// "New Weapon" default and would otherwise generate into <c>weapons/new_weapon</c>.
	/// </summary>
	internal static bool AdoptAssetFileName( WeaponAnimationDocument document, Asset? asset ) =>
		AdoptAssetFileName( document, asset?.Path );

	internal static bool AdoptAssetFileName( WeaponAnimationDocument document, string? assetPath )
	{
		if ( string.IsNullOrWhiteSpace( assetPath ) )
			return false;

		var fileName = Path.GetFileNameWithoutExtension( assetPath.Replace( '\\', '/' ) );
		var slug = WeaponAnimationDocument.Slugify( fileName );
		if ( string.IsNullOrWhiteSpace( slug ) )
			return false;

		var changed = false;
		if ( document.Name != fileName )
		{
			document.Name = fileName;
			changed = true;
		}

		document.Output ??= new OutputSettings();
		if ( document.Output.AssetName != slug )
		{
			document.Output.AssetName = slug;
			changed = true;
		}
		return changed;
	}

	private void RefreshTitle()
	{
		WindowTitle = ComposeWindowTitle(
			_asset?.Path ?? "",
			_controller.Document.Name,
			_controller.IsDirty );
		Title = WindowTitle;
	}

	internal static string ComposeWindowTitle(
		string assetPath,
		string documentName,
		bool dirty )
	{
		var fileName = string.IsNullOrWhiteSpace( assetPath )
			? documentName
			: Path.GetFileName( assetPath.Replace( '\\', '/' ) );
		if ( string.IsNullOrWhiteSpace( fileName ) )
			fileName = "New Weapon";
		return $"S&box Weapon Animator — {fileName}{(dirty ? " *" : "")}";
	}

	private void RefreshToolbarState()
	{
		if ( _validationButton is null )
			return;
		var report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate
			? WeaponAnimationValidator.ValidateCalibration( _controller.Document )
			: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );
		_validationButton.Text = report.IsValid
			? report.WarningCount > 0 ? $"{report.WarningCount} warnings" : "Valid"
			: $"{report.ErrorCount} errors";
		if ( _validationButton is WeaponAnimatorButton validationButton )
			validationButton.FitToContent( true );
		_validationButton.Icon = report.IsValid
			? report.WarningCount > 0 ? "warning" : "check_circle"
			: "error";
		_validationButton.Tint = report.IsValid
			? report.WarningCount > 0 ? WeaponAnimatorTheme.Amber * 0.45f : WeaponAnimatorTheme.Green * 0.45f
			: WeaponAnimatorTheme.Coral * 0.5f;
		if ( _playButton is not null )
		{
			_playButton.Text = _controller.IsPlaying ? "Pause" : "Play";
			_playButton.Icon = _controller.IsPlaying ? "pause" : "play_arrow";
			if ( _playButton is WeaponAnimatorButton playButton )
				playButton.FitToContent( true );
		}
		_toolbar?.BalanceCenter();
	}

	private bool OfferRecovery()
	{
		if ( _asset is null )
			return false;
		var writeUtc = File.Exists( _asset.AbsolutePath )
			? File.GetLastWriteTimeUtc( _asset.AbsolutePath )
			: DateTime.MinValue;
		var recovery = RecoveryService.ReadNewerThan( _controller.Document.DocumentId, writeUtc );
		if ( recovery is null )
			return false;

		Dialog.AskConfirm(
			() =>
			{
				var migration = MigrateAndRepair( recovery );
				if ( migration.Migrated )
				{
					_migration = migration;
					_migrationBackupRequired = true;
				}
				NormalizeRecoveredSource( recovery );
				if ( recovery.Source.Compiled )
					PreviewHostBuilder.Build( recovery );
				_controller.ReplaceWithoutHistory( recovery, true );
				BuildWorkspace();
				var message = migration.Migrated
					? $"Recovered the newer autosave snapshot. {migration.Summary}"
					: "Recovered the newer autosave snapshot.";
				_statusPanel?.SetMessage( message, ValidationSeverity.Warning );
			},
			() => RecoveryService.Clear( _controller.Document.DocumentId ),
			"A newer recovery snapshot exists for this project. Restore it?",
			"Recover Weapon Animation Project",
			"Restore",
			"Discard Recovery" );
		return true;
	}

	private void OfferCachedImportRecovery()
	{
		var document = _controller.Document;
		if ( !string.IsNullOrWhiteSpace( document.Source.SourcePath ) )
			return;

		var cachedSource = FindCachedSource( document.DocumentId );
		if ( string.IsNullOrWhiteSpace( cachedSource ) )
			return;

		Dialog.AskConfirm(
			() =>
			{
				var result = _importer.Import( document, cachedSource );
				var host = result.Success ? PreviewHostBuilder.Build( document ) : null;
				_controller.ReplaceWithoutHistory( document, true );
				BuildWorkspace();
				_statusPanel?.SetMessage(
					$"{result.Message} {host?.Message}",
					result.Success && host?.Success == true
						? ValidationSeverity.Info
						: ValidationSeverity.Error );
			},
			() => { },
			"The saved document is empty, but a previous weapon import remains in its private cache. Recover that import?",
			"Recover Cached Weapon Import",
			"Recover Import",
			"Ignore Cache" );
	}

	private static string FindCachedSource( Guid documentId )
	{
		var cache = WeaponSourceImporter.GetPreviewCacheRoot( documentId );
		if ( !Directory.Exists( cache ) )
			return "";

		var wrapper = Directory.EnumerateFiles( cache, "source_*.vmdl" )
			.OrderByDescending( File.GetLastWriteTimeUtc )
			.FirstOrDefault();
		if ( string.IsNullOrWhiteSpace( wrapper ) )
			return "";

		var match = Regex.Match(
			File.ReadAllText( wrapper ),
			"filename\\s*=\\s*\"(?<path>[^\"]+\\.(?:fbx|smd|dmx|vmdl))\"",
			RegexOptions.IgnoreCase );
		if ( !match.Success )
			return "";

		var relative = match.Groups["path"].Value;
		var absolute = global::Editor.FileSystem.Content.GetFullPath( relative );
		if ( File.Exists( absolute ) )
			return absolute;

		var directory = Path.GetDirectoryName( absolute );
		var filename = Path.GetFileName( absolute );
		if ( string.IsNullOrWhiteSpace( directory ) || !Directory.Exists( directory ) )
			return "";

		return Directory.EnumerateFiles( directory )
			.FirstOrDefault( path =>
				Path.GetFileName( path ).Equals( filename, StringComparison.OrdinalIgnoreCase ) )
			?? "";
	}

	private void NormalizeRecoveredSource( WeaponAnimationDocument document )
	{
		if ( !document.Source.Compiled
			|| !document.Source.NeedsModelDocWrapper
			|| string.IsNullOrWhiteSpace( document.Rig.RootBone )
			|| document.Rig.RootBone.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase )
			|| !string.IsNullOrWhiteSpace( document.Source.SourceRootBoneName ) )
			return;

		var source = string.IsNullOrWhiteSpace( document.Source.OriginalSourcePath )
			? document.Source.SourcePath
			: document.Source.OriginalSourcePath;
		_importer.Import( document, source );
	}

	private void CloseAfterPrompt( bool clearRecovery = true )
	{
		_closing = true;
		_recoveryRequestVersion++;
		if ( clearRecovery )
			RecoveryService.Clear( _controller.Document.DocumentId );
		_allowClose = true;
		Close();
	}

	private static WeaponAnimationMigrationResult MigrateAndRepair(
		WeaponAnimationDocument document ) =>
		WeaponAnimationMigration.MigrateAndRepair( document );
}

public static class WeaponAnimatorLauncher
{
	[Menu( "Editor", "Tools/Weapon Animator/Open Weapon Animator", "animation", Priority = 0 )]
	public static void OpenPicker()
	{
		new WeaponAnimatorPickerWindow().Show();
	}

	public static void CreateNew()
	{
		var dialog = new FileDialog( null )
		{
			Title = "Create Weapon Animation Project",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" ),
			DefaultSuffix = "wepanim"
		};
		dialog.SetModeSave();
		dialog.SetFindFile();
		dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
		if ( !dialog.Execute() )
			return;

		var path = Path.ChangeExtension( dialog.SelectedFile, ".wepanim" );
		var asset = AssetSystem.CreateResource( "wepanim", path );
		if ( asset is null )
			return;
		var resource = new WeaponAnimationAsset
		{
			Document = WeaponAnimationDocument.CreateDefault( Path.GetFileNameWithoutExtension( path ) )
		};
		asset.SaveToDisk( resource );
		IAssetEditor.OpenInEditor( asset, out _ );
	}

	public static void OpenExisting()
	{
		var dialog = new FileDialog( null )
		{
			Title = "Open Weapon Animation Project",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" )
		};
		dialog.SetModeOpen();
		dialog.SetFindExistingFile();
		dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
		if ( !dialog.Execute() )
			return;

		var asset = AssetSystem.FindByPath( dialog.SelectedFile )
			?? AssetSystem.RegisterFile( dialog.SelectedFile );
		if ( asset is not null )
			IAssetEditor.OpenInEditor( asset, out _ );
	}
}

internal sealed class WeaponAnimatorPickerWindow : Window
{
	public WeaponAnimatorPickerWindow()
	{
		DeleteOnClose = true;
		WindowTitle = "Weapon Animator";
		Title = WindowTitle;
		Size = new Vector2( 520, 260 );
		SetWindowIcon( "animation" );

		var root = new Widget( this );
		root.SetStyles( "background-color: rgb(13,15,17);" );
		root.Layout = Layout.Column();
		root.Layout.Margin = new Sandbox.UI.Margin( 28 );
		root.Layout.Spacing = 14;
		var title = WeaponAnimatorTheme.Label( "WEAPON ANIMATOR", root );
		title.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"font-size: 18px; font-weight: 600; letter-spacing: 1.2px; color: {WeaponAnimatorTheme.Text.Hex};" );
		root.Layout.Add( title );
		var description = WeaponAnimatorTheme.Label(
			"Open a document-driven import, calibration, binding, and animation workspace. No active scene or selected GameObject is required.",
			root,
			true );
		description.WordWrap = true;
		root.Layout.Add( description );
		var row = RigAuditPanel.Row( root );
		row.Layout.Add( WeaponAnimatorTheme.Button(
			"New project",
			"note_add",
			() =>
			{
				Close();
				WeaponAnimatorLauncher.CreateNew();
			},
			row,
			true ), 1 );
		row.Layout.Add( WeaponAnimatorTheme.Button(
			"Open existing",
			"folder_open",
			() =>
			{
				Close();
				WeaponAnimatorLauncher.OpenExisting();
			},
			row ), 1 );
		root.Layout.Add( row );
		root.Layout.AddStretchCell();
		Canvas = root;
	}
}

internal sealed class WeaponAnimatorPreferencesWindow : Window
{
	public WeaponAnimatorPreferencesWindow( WeaponAnimatorController controller )
	{
		DeleteOnClose = true;
		WindowTitle = "Weapon Animator Preferences";
		Title = WindowTitle;
		Size = new Vector2( 420, 520 );
		var root = new Widget( this );
		root.SetStyles( "background-color: rgb(13,15,17);" );
		root.Layout = Layout.Column();
		root.Layout.Margin = new Sandbox.UI.Margin( 18 );
		root.Layout.Spacing = 8;
		root.Layout.Add( Toggle(
			"Auto-key transformed controls",
			controller.Document.Workspace.AutoKey,
			value => controller.Mutate( "Auto-key preference", d => d.Workspace.AutoKey = value ) ) );
		root.Layout.Add( Toggle(
			"Use local gizmo space",
			controller.Document.Workspace.LocalGizmos,
			value => controller.Mutate( "Gizmo preference", d => d.Workspace.LocalGizmos = value ) ) );
		root.Layout.Add( Toggle(
			"Snap position",
			controller.Document.Workspace.SnapPosition,
			value => controller.Mutate( "Position snapping", d => d.Workspace.SnapPosition = value ) ) );
		root.Layout.Add( Toggle(
			"Snap rotation",
			controller.Document.Workspace.SnapRotation,
			value => controller.Mutate( "Rotation snapping", d => d.Workspace.SnapRotation = value ) ) );
		root.Layout.Add( Number(
			"Rotation snap angle",
			controller.Document.Workspace.RotationSnapDegrees,
			0.25f,
			180,
			value => controller.UpdateWorkspacePreference(
				"Rotation snap angle",
				workspace => workspace.RotationSnapDegrees = value ) ) );
		root.Layout.Add( WeaponAnimatorTheme.SectionLabel(
			"VIEWPORT GRID",
			root,
			topMargin: true ) );
		root.Layout.Add( Number(
			"Grid opacity",
			controller.Document.Workspace.GridOpacity,
			0,
			0.5f,
			value => controller.UpdateWorkspacePreference(
				"Grid opacity",
				workspace => workspace.GridOpacity = value ) ) );
		root.Layout.Add( Number(
			"Grid line weight",
			controller.Document.Workspace.GridLineThickness,
			0.1f,
			2,
			value => controller.UpdateWorkspacePreference(
				"Grid line weight",
				workspace => workspace.GridLineThickness = value ) ) );
		root.Layout.Add( WeaponAnimatorTheme.SectionLabel(
			"VIEWPORT LIGHTING",
			root,
			topMargin: true ) );
		root.Layout.Add( Toggle(
			"Cyan edge light",
			controller.Document.Workspace.RimLightEnabled,
			value => controller.UpdateWorkspacePreference(
				"Cyan edge light",
				workspace => workspace.RimLightEnabled = value ) ) );
		root.Layout.Add( Number(
			"Cyan edge brightness",
			controller.Document.Workspace.RimLightIntensity,
			0,
			12,
			value => controller.UpdateWorkspacePreference(
				"Cyan edge brightness",
				workspace => workspace.RimLightIntensity = value ) ) );
		var lightingNote = WeaponAnimatorTheme.Label(
			"The edge light is disabled automatically in Full Bright.",
			root,
			true );
		lightingNote.WordWrap = true;
		root.Layout.Add( lightingNote );
		root.Layout.AddStretchCell();
		root.Layout.Add( WeaponAnimatorTheme.Button( "Close", "close", Close, root, true ) );
		Canvas = root;

		Button Toggle( string text, bool value, Action<bool> changed )
		{
			var button = new WeaponAnimatorButton( text, root )
			{
				IsToggle = true,
				IsChecked = value,
				Tint = WeaponAnimatorTheme.SurfaceRaised
			};
			button.Toggled = () => changed( button.IsChecked );
			return button;
		}

		Widget Number(
			string text,
			float value,
			float minimum,
			float maximum,
			Action<float> changed )
		{
			var container = new Widget( root );
			container.Layout = Layout.Column();
			container.Layout.Margin = 0;
			container.Layout.Spacing = 3;
			var row = RigAuditPanel.Row( container );
			row.Layout.Add( WeaponAnimatorTheme.Label( text, row, true ), 1 );
			var edit = new LineEdit( row )
			{
				Text = value.ToString( "0.##", CultureInfo.InvariantCulture ),
				FixedWidth = 82,
				FixedHeight = 27
			};
			edit.SetStyles( WeaponAnimatorTheme.InputStyle );
			var slider = new FloatSlider( container )
			{
				Minimum = minimum,
				Maximum = maximum,
				Value = value,
				FixedHeight = 18
			};

			void Apply( float candidate, bool updateEdit )
			{
				var clamped = Math.Clamp( candidate, minimum, maximum );
				if ( updateEdit )
					edit.Text = clamped.ToString( "0.##", CultureInfo.InvariantCulture );
				slider.Value = clamped;
				changed( clamped );
			}

			edit.TextEdited += textValue =>
			{
				if ( float.TryParse(
					textValue,
					NumberStyles.Float,
					CultureInfo.InvariantCulture,
					out var parsed )
					&& WeaponAnimationMath.IsFinite( parsed ) )
					Apply( parsed, false );
			};
			edit.EditingFinished += () => Apply( slider.Value, true );
			slider.OnValueEdited = () => Apply( slider.Value, true );
			row.Layout.Add( edit );
			container.Layout.Add( row );
			container.Layout.Add( slider );
			return container;
		}
	}
}
#nullable enable annotations

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

namespace SboxWeaponAnimator.Editor;

public sealed class ClipRackPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly ScrollArea _clipScroll;
	private readonly Widget _clipCanvas;
	private readonly ScrollArea _propertiesScroll;
	private readonly Widget _propertiesCanvas;
	private readonly Label _actionHint;
	private readonly Dictionary<Guid, WeaponAnimatorButton> _clipButtons = [];
	private readonly Dictionary<Guid, int> _propertyScrollByClip = [];
	private string _clipListSignature = "";
	private Guid _lastSelectedClipId;

	public event Action<string, ValidationSeverity>? StatusChanged;

	public ClipRackPanel(
		WeaponAnimatorController controller,
		Widget? parent = null,
		bool showClipHeader = true ) : base( parent )
	{
		_controller = controller;
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 8 );
		Layout.Spacing = 6;

		if ( showClipHeader )
			Layout.Add( Header( "CLIP RACK", this ) );
		_clipScroll = new ScrollArea( this )
		{
			MinimumSize = new Vector2( 200, 70 )
		};
		_clipCanvas = new Widget( _clipScroll );
		_clipCanvas.Layout = Layout.Column();
		_clipCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
		_clipCanvas.Layout.Spacing = 2;
		_clipScroll.Canvas = _clipCanvas;
		Layout.Add( _clipScroll, 2 );

		var actions = RigAuditPanel.Row( this );
		actions.Layout.Add( WeaponAnimatorTheme.Button(
			"Start",
			"add_circle",
			StartSelectedFromDefault,
			actions,
			true ), 1 );
		actions.Layout.Add( WeaponAnimatorTheme.Button(
			"Duplicate",
			"content_copy",
			ShowDuplicateMenu,
			actions ), 1 );
		actions.Layout.Add( WeaponAnimatorTheme.Button(
			"Import",
			"input",
			ShowImportMenu,
			actions ), 1 );
		Layout.Add( actions );

		_actionHint = WeaponAnimatorTheme.Label( "", this, true );
		_actionHint.WordWrap = true;
		Layout.Add( _actionHint );

		_propertiesScroll = new ScrollArea( this ) { MinimumHeight = 80 };
		_propertiesCanvas = new Widget( _propertiesScroll );
		_propertiesCanvas.Layout = Layout.Column();
		_propertiesCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
		_propertiesCanvas.Layout.Spacing = 4;
		_propertiesScroll.Canvas = _propertiesCanvas;
		Layout.Add( _propertiesScroll, 1 );

		var addCustom = WeaponAnimatorTheme.Button(
			"Add custom clip",
			"playlist_add",
			AddCustomClip,
			this );
		Layout.Add( addCustom );

		_controller.DocumentChanged += Rebuild;
		Rebuild();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Rebuild;
		_controller.SelectionChanged -= Rebuild;
		base.OnDestroyed();
	}

	private void Rebuild()
	{
		var clipScroll = _clipScroll.VerticalScrollbar.Value;
		var selectedClipId = _controller.Document.Workspace.SelectedClipId;
		var propertiesScroll = CapturePropertiesScroll( selectedClipId );
		var clipSignature = ClipListSignature();
		if ( _clipListSignature != clipSignature || _clipButtons.Count == 0 )
		{
			_clipCanvas.Layout.Clear( true );
			_clipButtons.Clear();

			AddClipGroup( "CORE", [
				WeaponClipRole.Idle, WeaponClipRole.Deploy, WeaponClipRole.Fire,
				WeaponClipRole.FireDry, WeaponClipRole.Reload, WeaponClipRole.ReloadEmpty,
				WeaponClipRole.Holster
			] );
			AddClipGroup( "PRESENTATION", [
				WeaponClipRole.Inspect, WeaponClipRole.Sprint, WeaponClipRole.Jump,
				WeaponClipRole.Lower, WeaponClipRole.Ironsights
			] );
			AddClipGroup( "INTERACTION", [
				WeaponClipRole.GrabStance, WeaponClipRole.GrabGestureOne,
				WeaponClipRole.GrabGestureTwo, WeaponClipRole.GrabGestureThree,
				WeaponClipRole.GrabGestureFour
			] );
			AddClipGroup( "INCREMENTAL", [
				WeaponClipRole.ReloadEnter, WeaponClipRole.FirstShell,
				WeaponClipRole.InsertShell, WeaponClipRole.ReloadExit
			] );

			var custom = _controller.Document.Clips
				.Where( x => x.Role == WeaponClipRole.Custom )
				.ToArray();
			if ( custom.Length > 0 )
			{
				_clipCanvas.Layout.Add( Header( "CUSTOM", _clipCanvas ) );
				foreach ( var clip in custom )
					AddClipButton( clip );
			}
			_clipCanvas.Layout.AddStretchCell();
			_clipListSignature = ClipListSignature();
			_clipCanvas.UpdateGeometry();
			_clipScroll.VerticalScrollbar.Value = clipScroll;
		}
		else
		{
			RefreshClipButtons();
		}

		_propertiesCanvas.Layout.Clear( true );

		var selected = _controller.Document.GetSelectedClip();
		_actionHint.Text = selected is null
			? "Select a clip."
			: selected.Readiness == ClipReadiness.NotStarted
				? "Not started · choose Start, Duplicate, or Import."
				: $"{selected.Readiness} · {selected.Duration:0.###} s at {selected.SampleRate:0.#} fps";
		BuildClipProperties( selected );
		_propertiesCanvas.UpdateGeometry();
		_propertiesScroll.VerticalScrollbar.Value = propertiesScroll;
		_lastSelectedClipId = selectedClipId;
	}

	private void AddClipGroup( string name, IEnumerable<WeaponClipRole> roles )
	{
		_clipCanvas.Layout.Add( Header( name, _clipCanvas ) );
		foreach ( var role in roles )
		{
			var clip = _controller.Document.EnsureClip( role );
			AddClipButton( clip );
		}
	}

	private void BuildClipProperties( WeaponAnimationClip? clip )
	{
		if ( _propertiesCanvas is null || clip is null )
			return;
		_propertiesCanvas.Layout.Add( Header( "CLIP PROPERTIES", _propertiesCanvas ) );
		if ( clip.Role == WeaponClipRole.Custom )
			AddCustomClipProperties( clip );
		var sequence = WeaponAnimatorTheme.Label(
			$"Sequence: {WeaponAnimationNames.SequenceName( clip )}",
			_propertiesCanvas,
			true );
		sequence.ToolTip = "Generated sequence name";
		_propertiesCanvas.Layout.Add( sequence );
		AddClipNumber(
			"Duration",
			clip.Duration,
			value => _controller.Mutate( "Clip duration", _ =>
			{
				clip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );
				clip.KeysClampToDuration();
			} ) );
		AddClipNumber(
			"Sample rate",
			clip.SampleRate,
			value => _controller.Mutate(
				"Clip sample rate",
				_ => clip.SampleRate = Math.Clamp( value, 1, 240 ) ) );
		_propertiesCanvas.Layout.Add( ClipChoice(
			$"Readiness: {clip.Readiness}",
			Enum.GetNames<ClipReadiness>(),
			value => _controller.Mutate(
				"Clip readiness",
				_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );
		_propertiesCanvas.Layout.Add( ClipChoice(
			$"Interpolation: {DominantInterpolation( clip )}",
			Enum.GetNames<TrackInterpolation>(),
			value => _controller.Mutate( "Track interpolation", _ =>
			{
				var interpolation = Enum.Parse<TrackInterpolation>( value );
				foreach ( var track in clip.Tracks )
					track.Interpolation = interpolation;
			} ) ) );
		_propertiesCanvas.Layout.Add( Header( "TAGS", _propertiesCanvas ) );
		var tagRow = RigAuditPanel.Row( _propertiesCanvas );
		var name = new LineEdit( tagRow )
		{
			PlaceholderText = "Tag name",
			FixedHeight = 27
		};
		name.SetStyles( WeaponAnimatorTheme.InputStyle );
		tagRow.Layout.Add( name, 1 );
		tagRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Point",
			"add_location",
			() => AddClipTag( name.Text, AnimationTagKind.Point ),
			tagRow ) );
		tagRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Range",
			"linear_scale",
			() => AddClipTag( name.Text, AnimationTagKind.Range ),
			tagRow ) );
		_propertiesCanvas.Layout.Add( tagRow );
		foreach ( var tag in clip.Tags )
		{
			_propertiesCanvas.Layout.Add( WeaponAnimatorTheme.Label(
				$"{tag.Name}  {tag.StartTime:0.###}–{tag.EndTime:0.###}",
				_propertiesCanvas,
				true ) );
		}
		_propertiesCanvas.Layout.AddStretchCell();
	}

	private void AddCustomClipProperties( WeaponAnimationClip clip )
	{
		if ( _propertiesCanvas is null )
			return;

		var nameRow = RigAuditPanel.Row( _propertiesCanvas );
		nameRow.Layout.Add( WeaponAnimatorTheme.Label( "Name", nameRow, true ) );
		var name = new LineEdit( nameRow )
		{
			Text = clip.Name,
			FixedHeight = 26
		};
		name.SetStyles( WeaponAnimatorTheme.InputStyle );
		name.EditingFinished += () =>
		{
			var renamed = name.Text.Trim();
			if ( string.IsNullOrWhiteSpace( renamed ) )
			{
				name.Text = clip.Name;
				StatusChanged?.Invoke(
					"A custom clip name cannot be empty.",
					ValidationSeverity.Warning );
				return;
			}
			_controller.RenameCustomClip( clip.Id, renamed );
		};
		nameRow.Layout.Add( name, 1 );
		_propertiesCanvas.Layout.Add( nameRow );

		var delete = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(
			"Delete custom clip",
			"delete",
			() => RequestDeleteCustomClip( clip ),
			_propertiesCanvas );
		delete.Tint = WeaponAnimatorTheme.Coral * 0.38f;
		_propertiesCanvas.Layout.Add( delete );
	}

	private void RequestDeleteCustomClip( WeaponAnimationClip clip )
	{
		Dialog.AskConfirm(
			() => _controller.DeleteCustomClip( clip.Id ),
			$"Delete the custom clip '{clip.Name}' and all of its keys, curves, tags, and visibility tracks?",
			"Delete Custom Clip",
			"Delete",
			"Cancel" );
	}

	private void AddClipNumber( string label, float value, Action<float> changed )
	{
		if ( _propertiesCanvas is null )
			return;
		var row = RigAuditPanel.Row( _propertiesCanvas );
		row.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );
		var edit = new LineEdit( row )
		{
			Text = value.ToString( "0.###", CultureInfo.InvariantCulture ),
			FixedWidth = 84,
			FixedHeight = 26
		};
		edit.SetStyles( WeaponAnimatorTheme.InputStyle );
		edit.EditingFinished += () =>
		{
			if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed )
				&& WeaponAnimationMath.IsFinite( parsed ) )
				changed( parsed );
		};
		row.Layout.Add( edit );
		_propertiesCanvas.Layout.Add( row );
	}

	private Button ClipChoice(
		string text,
		IEnumerable<string> values,
		Action<string> changed )
	{
		var button = new WeaponAnimatorButton( text, "expand_more", _propertiesCanvas )
		{
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Clicked = () =>
		{
			var menu = new Menu( button );
			foreach ( var value in values )
			{
				var captured = value;
				menu.AddOption( captured, null, () => changed( captured ) );
			}
			menu.OpenAt( button.ScreenRect.BottomLeft );
		};
		return button;
	}

	private void AddClipTag( string name, AnimationTagKind kind )
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null || string.IsNullOrWhiteSpace( name ) )
			return;
		_controller.Mutate( $"Add tag {name}", document =>
		{
			var start = document.Workspace.TimelineTime;
			clip.Tags.Add( new AnimationTag
			{
				Name = name.Trim(),
				Kind = kind,
				StartTime = start,
				EndTime = kind == AnimationTagKind.Range
					? MathF.Min( start + 0.1f, clip.Duration )
					: start
			} );
		} );
	}

	private static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>
		clip.Tracks.GroupBy( x => x.Interpolation )
			.OrderByDescending( x => x.Count() )
			.Select( x => x.Key )
			.FirstOrDefault();

	private void AddClipButton( WeaponAnimationClip clip )
	{
		var button = new WeaponAnimatorButton( "", _clipCanvas )
		{
			Clicked = () => _controller.SelectClip( clip.Id )
		};
		ApplyClipButtonAppearance( button, clip );
		_clipCanvas.Layout.Add( button );
		_clipButtons[clip.Id] = button;
	}

	private void StartSelectedFromDefault()
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null )
			return;

		_controller.Mutate( $"Start {clip.Name}", document =>
		{
			document.Workspace.ClearWorkingPoses( clip.Id );
			document.Workspace.TimelineViews.RemoveAll( x => x.ClipId == clip.Id );
			document.Workspace.CurveViews.RemoveAll( x => x.ClipId == clip.Id );
			clip.VisibilityTracks.Clear();
			var skeleton = HostSkeletonBuilder.BuildCached( document );
			if ( clip.Role == WeaponClipRole.Idle )
			{
				IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
				return;
			}

			clip.Tracks.Clear();
			clip.IsBindPoseSeed = false;
			foreach ( var bone in skeleton.Bones )
			{
				var track = clip.EnsureTrack( bone.Name );
				track.Kind = bone.IsWeaponBone ? RigControlKind.Weapon : RigControlKind.Arm;
				var gripTransform = document.Binding.GripPoses
					.FirstOrDefault( x => x.Id == document.Binding.DefaultGripPoseId )?
					.Bones.FirstOrDefault( x => x.BoneName.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) )?
					.LocalTransform;
				WeaponAnimationMath.UpsertKey(
					track,
					0,
					gripTransform ?? skeleton.GetBindLocal( bone ) );
			}
			clip.Readiness = clip.Role == WeaponClipRole.Idle
				? ClipReadiness.Ready
				: ClipReadiness.Draft;
		} );
	}

	private void ShowDuplicateMenu()
	{
		var selected = _controller.Document.GetSelectedClip();
		if ( selected is null )
			return;
		var menu = new Menu( this );
		foreach ( var source in _controller.Document.Clips.Where( x =>
			x.Id != selected.Id && x.Readiness != ClipReadiness.NotStarted ) )
		{
			var captured = source;
			menu.AddOption( captured.Name, null, () => Duplicate( captured, selected ) );
		}
		menu.OpenAtCursor();
	}

	private void Duplicate( WeaponAnimationClip source, WeaponAnimationClip destination )
	{
		_controller.Mutate( $"Duplicate {source.Name}", _ =>
		{
			_controller.Document.Workspace.ClearWorkingPoses( destination.Id );
			_controller.Document.Workspace.TimelineViews.RemoveAll( x =>
				x.ClipId == destination.Id );
			_controller.Document.Workspace.CurveViews.RemoveAll( x =>
				x.ClipId == destination.Id );
			var copy = Json.Deserialize<WeaponAnimationClip>( Json.Serialize( source ) )!;
			destination.Duration = copy.Duration;
			destination.SampleRate = copy.SampleRate;
			destination.AllowSubframeKeys = copy.AllowSubframeKeys;
			destination.IsBindPoseSeed = false;
			destination.Tracks = copy.Tracks;
			destination.VisibilityTracks = copy.VisibilityTracks;
			destination.Constraints = copy.Constraints;
			destination.Tags = copy.Tags;
			destination.Readiness = ClipReadiness.Draft;
		} );
	}

	private void ShowImportMenu()
	{
		var selected = _controller.Document.GetSelectedClip();
		if ( selected is null )
			return;
		var sequences = SequenceImportService.GetSequences( _controller.Document );
		if ( sequences.Count == 0 )
		{
			StatusChanged?.Invoke( "The source model exposes no importable sequences.", ValidationSeverity.Warning );
			return;
		}

		var menu = new Menu( this );
		foreach ( var sequence in sequences )
		{
			var captured = sequence;
			menu.AddOption( captured, null, () =>
			{
				SequenceImportResult? result = null;
				_controller.Mutate( $"Import {captured}", document =>
				{
					document.Workspace.ClearWorkingPoses( selected.Id );
					document.Workspace.TimelineViews.RemoveAll( x =>
						x.ClipId == selected.Id );
					document.Workspace.CurveViews.RemoveAll( x =>
						x.ClipId == selected.Id );
					selected.IsBindPoseSeed = false;
					result = SequenceImportService.Import( document, selected, captured );
				} );
				StatusChanged?.Invoke(
					result?.Message ?? "Sequence import failed.",
					result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
			} );
		}
		menu.OpenAtCursor();
	}

	private void AddCustomClip()
	{
		_controller.Mutate( "Add custom clip", document =>
		{
			var count = document.Clips.Count( x => x.Role == WeaponClipRole.Custom ) + 1;
			var clip = WeaponAnimationClip.Create( WeaponClipRole.Custom );
			clip.Name = $"Custom {count}";
			document.Clips.Add( clip );
			WeaponAnimationNames.RepairCustomSequenceNames( document );
			document.Workspace.SelectedClipId = clip.Id;
		} );
	}

	private static Label Header( string text, Widget parent )
	{
		var label = WeaponAnimatorTheme.SectionLabel( text, parent );
		label.FixedHeight = 22;
		label.SetStyles(
			"background-color: transparent; border: none; padding: 5px 0 0 0;" +
			$"font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Muted.Hex};" );
		return label;
	}

	private int CapturePropertiesScroll( Guid selectedClipId )
	{
		if ( _propertiesScroll is null )
			return 0;

		if ( _lastSelectedClipId != Guid.Empty )
			_propertyScrollByClip[_lastSelectedClipId] =
				_propertiesScroll.VerticalScrollbar.Value;
		return _lastSelectedClipId == selectedClipId
			? _propertiesScroll.VerticalScrollbar.Value
			: _propertyScrollByClip.GetValueOrDefault( selectedClipId );
	}

	private string ClipListSignature() => string.Join(
		"|",
		_controller.Document.Clips.Select( x =>
			$"{x.Id}:{x.Role}:{x.Name}:{x.Readiness}" ) );

	private void RefreshClipButtons()
	{
		foreach ( var clip in _controller.Document.Clips )
		{
			if ( !_clipButtons.TryGetValue( clip.Id, out var button ) )
				continue;
			ApplyClipButtonAppearance( button, clip );
		}
	}

	private void ApplyClipButtonAppearance(
		WeaponAnimatorButton button,
		WeaponAnimationClip clip )
	{
		var marker = clip.Readiness switch
		{
			ClipReadiness.NotStarted => "○",
			ClipReadiness.Draft => "◐",
			ClipReadiness.Ready => "●",
			_ => "!"
		};
		button.Text = $"{marker}  {clip.Name}";
		button.Tint = clip.Id == _controller.Document.Workspace.SelectedClipId
			? WeaponAnimatorTheme.Cyan * 0.42f
			: clip.Readiness switch
			{
				ClipReadiness.Ready => WeaponAnimatorTheme.Green * 0.24f,
				ClipReadiness.Warning => WeaponAnimatorTheme.Coral * 0.28f,
				_ => WeaponAnimatorTheme.Surface
			};
		button.ToolTip = clip.Readiness.ToString();
	}

	internal ScrollArea ClipScroll => _clipScroll;
	internal ScrollArea? PropertiesScroll => _propertiesScroll;
	internal WeaponAnimatorButton? GetClipButton( Guid clipId ) =>
		_clipButtons.GetValueOrDefault( clipId );
}

public sealed class AnimationInspectorPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly Widget _canvas;
	private readonly bool _controlToolsOnly;
	private readonly Dictionary<string, bool> _expandedSections = new( StringComparer.OrdinalIgnoreCase )
	{
		["binding"] = true,
		["constraints"] = true,
		["animgraph"] = false
	};
	public event Action<string, ValidationSeverity>? StatusChanged;

	public AnimationInspectorPanel(
		WeaponAnimatorController controller,
		Widget? parent = null,
		bool controlToolsOnly = false ) : base( parent )
	{
		_controller = controller;
		_controlToolsOnly = controlToolsOnly;
		Layout = Layout.Column();
		Layout.Margin = 0;
		var scroll = new ScrollArea( this );
		_canvas = new Widget( scroll );
		_canvas.Layout = Layout.Column();
		_canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin( 10 );
		_canvas.Layout.Spacing = 7;
		scroll.Canvas = _canvas;
		Layout.Add( scroll, 1 );

		_controller.DocumentChanged += Rebuild;
		_controller.SelectionChanged += Rebuild;
		Rebuild();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Rebuild;
		_controller.SelectionChanged -= Rebuild;
		base.OnDestroyed();
	}

	private void Rebuild()
	{
		_canvas?.Layout.Clear( true );
		if ( _canvas is null )
			return;

		if ( !_controlToolsOnly )
		{
			_canvas.Layout.Add( Header( "CONTROL INSPECTOR" ) );
			_canvas.Layout.Add( WeaponAnimatorTheme.Label( SelectionName(), _canvas ) );
		}

		var bindingCanvas = _controlToolsOnly
			? AddCollapsibleSection( "BINDING + GRIP POSES", "binding" )
			: _canvas;
		var selectedControl = _controller.Document.Workspace.SelectedControl;
		if ( !string.IsNullOrWhiteSpace( selectedControl ) )
		{
			var selectedTarget = ResolveControl( selectedControl );
			if ( selectedTarget is not null
				&& selectedControl is "@primary_hand" or "@support_hand" )
			{
				var instruction = WeaponAnimatorTheme.Label(
					"Keep this hand selected. Choose its attachment bone from the menu below; "
					+ "you do not need to select the weapon bone in the rig browser.",
					bindingCanvas,
					true );
				instruction.WordWrap = true;
				bindingCanvas.Layout.Add( instruction );

				var weaponBones = HostSkeletonBuilder.BuildCached( _controller.Document )
					.Bones
					.Where( x => x.IsWeaponBone )
					.Select( x => x.Name )
					.Distinct( StringComparer.OrdinalIgnoreCase )
					.ToList();
				bindingCanvas.Layout.Add( ChoiceButton(
					"Attachment bone",
					() => string.IsNullOrWhiteSpace( selectedTarget.AttachedBone )
						? "weapon_root (recommended on bind)"
						: selectedTarget.AttachedBone,
					weaponBones.Prepend( "(world)" ),
					value => _controller.Mutate( "Change hand attachment", document =>
					{
						HandAttachmentService.ChangeAttachment(
							document,
							selectedControl,
							value == "(world)" ? "" : value );
					} ),
					bindingCanvas ) );

				bindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(
					selectedTarget.IsBound ? $"Unbind {selectedTarget.Name}" : $"Bind {selectedTarget.Name}",
					selectedTarget.IsBound ? "link_off" : "link",
					() => ToggleHandBinding( selectedControl ),
					bindingCanvas,
					!selectedTarget.IsBound ) );
			}
		}

		var bindingRow = RigAuditPanel.Row( bindingCanvas );
		bindingRow.Layout.Add( WeaponAnimatorTheme.Button(
			_controller.Document.Binding.Configuration == GripConfiguration.TwoHanded
				? "Two handed"
				: "One handed",
			"pan_tool",
			ToggleGripConfiguration,
			bindingRow ), 1 );
		bindingRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Save grip pose",
			"save",
			SaveGripPose,
			bindingRow,
			true ), 1 );
		bindingCanvas.Layout.Add( bindingRow );
		bindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Apply saved grip pose",
			"front_hand",
			ShowGripPoseMenu,
			bindingCanvas ) );

		var clip = _controller.Document.GetSelectedClip();
		if ( clip is not null && !_controlToolsOnly )
		{
			_canvas.Layout.Add( Header( "CLIP PROPERTIES" ) );
			_canvas.Layout.Add( NumericField(
				"Duration (seconds)",
				clip.Duration,
				value => _controller.Mutate( "Clip duration", _ =>
				{
					clip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );
					clip.KeysClampToDuration();
				} ) ) );
			_canvas.Layout.Add( NumericField(
				"Sample rate",
				clip.SampleRate,
				value => _controller.Mutate( "Clip sample rate", _ =>
					clip.SampleRate = Math.Clamp( value, 1, 240 ) ) ) );
			_canvas.Layout.Add( ChoiceButton(
				"Readiness",
				() => clip.Readiness.ToString(),
				Enum.GetNames<ClipReadiness>(),
				value => _controller.Mutate(
					"Clip readiness",
					_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );
			_canvas.Layout.Add( ChoiceButton(
				"Interpolation",
				() => DominantInterpolation( clip ).ToString(),
				Enum.GetNames<TrackInterpolation>(),
				value => _controller.Mutate( "Track interpolation", _ =>
				{
					var interpolation = Enum.Parse<TrackInterpolation>( value );
					foreach ( var track in clip.Tracks )
						track.Interpolation = interpolation;
				} ) ) );
		}

		var constraintCanvas = _controlToolsOnly
			? AddCollapsibleSection( "CONSTRAINTS", "constraints" )
			: _canvas;
		if ( !_controlToolsOnly )
			constraintCanvas.Layout.Add( Header( "KEYING + CONSTRAINTS" ) );
		if ( !_controlToolsOnly )
		{
			var toggles = RigAuditPanel.Row( constraintCanvas );
			toggles.Layout.Add( ToggleButton(
				"Auto-key",
				_controller.Document.Workspace.AutoKey,
				value => _controller.Mutate( "Auto-key", d => d.Workspace.AutoKey = value ),
				toggles ), 1 );
			toggles.Layout.Add( ToggleButton(
				"Local gizmo",
				_controller.Document.Workspace.LocalGizmos,
				value => _controller.Mutate( "Gizmo space", d => d.Workspace.LocalGizmos = value ),
				toggles ), 1 );
			constraintCanvas.Layout.Add( toggles );
		}
		constraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Constraint target",
			"target",
			ShowConstraintTargetMenu,
			constraintCanvas ) );
		constraintCanvas.Layout.Add( WeaponAnimatorTheme.Label(
			string.IsNullOrWhiteSpace( _controller.Document.Workspace.ConstraintTargetBone )
				? "No constraint target selected"
				: _controller.Document.Workspace.ConstraintTargetBone,
			constraintCanvas,
			true ) );
		constraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Constrain selected control",
			"link",
			AddConstraint,
			constraintCanvas ) );

		if ( !_controlToolsOnly )
		{
			_canvas.Layout.Add( Header( "TAGS" ) );
			var tagRow = RigAuditPanel.Row( _canvas );
			var tagName = new LineEdit( tagRow )
			{
				PlaceholderText = "Tag name",
				FixedHeight = 28
			};
			tagName.SetStyles( WeaponAnimatorTheme.InputStyle );
			tagRow.Layout.Add( tagName, 1 );
			tagRow.Layout.Add( WeaponAnimatorTheme.Button(
				"Point",
				"add_location",
				() => AddTag( tagName.Text, AnimationTagKind.Point ),
				tagRow ) );
			tagRow.Layout.Add( WeaponAnimatorTheme.Button(
				"Range",
				"linear_scale",
				() => AddTag( tagName.Text, AnimationTagKind.Range ),
				tagRow ) );
			_canvas.Layout.Add( tagRow );

			if ( clip is not null )
			{
				foreach ( var tag in clip.Tags )
					_canvas.Layout.Add( WeaponAnimatorTheme.Label(
						$"{tag.Name}  {tag.StartTime:0.###}–{tag.EndTime:0.###}",
						_canvas,
						true ) );
			}
		}

		var graphCanvas = _controlToolsOnly
			? AddCollapsibleSection( "ANIMGRAPH PREVIEW", "animgraph" )
			: _canvas;
		if ( !_controlToolsOnly )
			graphCanvas.Layout.Add( Header( "ANIMGRAPH PREVIEW" ) );
		var graphActions = new[]
		{
			("Fire", "b_attack", WeaponClipRole.Fire),
			("Dry", "b_attack_dry", WeaponClipRole.FireDry),
			("Reload", "b_reload", WeaponClipRole.Reload),
			("Sprint", "b_sprint", WeaponClipRole.Sprint),
			("Inspect", "b_inspect", WeaponClipRole.Inspect)
		};
		var graphRows = new[]
		{
			RigAuditPanel.Row( graphCanvas ),
			RigAuditPanel.Row( graphCanvas )
		};
		for ( var index = 0; index < graphActions.Length; index++ )
		{
			var captured = graphActions[index];
			var row = graphRows[index < 3 ? 0 : 1];
			row.Layout.Add( WeaponAnimatorTheme.Button(
				captured.Item1,
				"play_arrow",
				() => SimulateParameter( captured.Item2, captured.Item3 ),
				row ), 1 );
		}
		graphCanvas.Layout.Add( graphRows[0] );
		graphCanvas.Layout.Add( graphRows[1] );
		graphCanvas.Layout.Add( NumericField(
			"move_bob",
			_controller.Document.Graph.PreviewFloats.GetValueOrDefault( "move_bob" ),
			value => _controller.Mutate( "Preview move_bob", d =>
				d.Graph.PreviewFloats["move_bob"] = Math.Clamp( value, 0, 1 ) ),
			graphCanvas ) );
		_canvas.Layout.AddStretchCell();
	}

	private Widget AddCollapsibleSection( string title, string id )
	{
		var expanded = _expandedSections.GetValueOrDefault( id );
		var header = new WeaponAnimatorButton(
			$"{(expanded ? "▾" : "▸")}  {title}",
			_canvas )
		{
			Clicked = () =>
			{
				_expandedSections[id] = !expanded;
				Rebuild();
			},
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		header.FixedHeight = 26;
		_canvas.Layout.Add( header );

		var body = new Widget( _canvas )
		{
			Visible = expanded,
			Layout = Layout.Column()
		};
		body.Layout.Margin = new Sandbox.UI.Margin( 2, 2, 2, 5 );
		body.Layout.Spacing = 6;
		_canvas.Layout.Add( body );
		return body;
	}

	private void ToggleHandBinding( string controlName )
	{
		var target = ResolveControl( controlName );
		if ( target is null )
			return;
		if ( !target.IsBound
			&& controlName == "@primary_hand"
			&& _controller.Document.Calibration.GetAnchor( AnchorKind.Grip ) is null )
		{
			StatusChanged?.Invoke(
				"Set the primary grip anchor in Calibrate before binding the primary hand.",
				ValidationSeverity.Warning );
			return;
		}

		_controller.Mutate(
			target.IsBound ? $"Unbind {target.Name}" : $"Bind {target.Name}",
			document =>
			{
				var bindingTarget = ResolveControl( controlName );
				if ( bindingTarget is null )
					return;

				if ( !bindingTarget.IsBound && controlName == "@primary_hand" )
					CalibrationBindingSeeder.SeedDefaultPrimaryHand( document );
				bindingTarget.IsBound = !bindingTarget.IsBound;
				bindingTarget.Reachable = true;

				var checklistId = controlName == "@primary_hand"
					? "primary_hand"
					: "support_hand";
				if ( bindingTarget.IsBound
					&& !document.Binding.CompletedChecklistItems.Contains( checklistId ) )
					document.Binding.CompletedChecklistItems.Add( checklistId );
			} );
	}

	private void SaveGripPose()
	{
		var document = _controller.Document;
		var skeleton = HostSkeletonBuilder.BuildCached( document );
		var pose = AnimationPoseEvaluator.Evaluate(
			document,
			skeleton,
			document.GetSelectedClip(),
			document.Workspace.TimelineTime,
			includeWorkingPose: true );
		_controller.Mutate( "Save default grip pose", d =>
		{
			var grip = new GripPose
			{
				Name = $"Grip {d.Binding.GripPoses.Count + 1}",
				Bones = skeleton.Bones
					.Where( x => !x.IsWeaponBone
						&& (x.Name.Contains( "finger_", StringComparison.OrdinalIgnoreCase )
							|| x.Name.Contains( "clavicle_", StringComparison.OrdinalIgnoreCase )
							|| x.Name.Contains( "hand_", StringComparison.OrdinalIgnoreCase )) )
					.Select( x => new BonePose
					{
						BoneName = x.Name,
						LocalTransform = pose.Local[x.Name]
					} )
					.ToList()
			};
			d.Binding.GripPoses.Add( grip );
			d.Binding.DefaultGripPoseId = grip.Id;
			d.Binding.CompletedChecklistItems.Add( "default_grip" );
		} );
	}

	private void ToggleGripConfiguration()
	{
		_controller.Mutate( "Grip configuration", d =>
			d.Binding.Configuration = d.Binding.Configuration == GripConfiguration.TwoHanded
				? GripConfiguration.OneHanded
				: GripConfiguration.TwoHanded );
	}

	private void ShowGripPoseMenu()
	{
		if ( _controller.Document.Binding.GripPoses.Count == 0 )
		{
			StatusChanged?.Invoke( "No reusable grip poses have been saved.", ValidationSeverity.Warning );
			return;
		}

		var menu = new Menu( this );
		foreach ( var grip in _controller.Document.Binding.GripPoses )
		{
			var captured = grip;
			menu.AddOption( captured.Name, null, () => ApplyGripPose( captured ) );
		}
		menu.OpenAtCursor();
	}

	private void ApplyGripPose( GripPose pose )
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null )
			return;

		_controller.Mutate( $"Apply {pose.Name}", document =>
		{
			var time = document.Workspace.TimelineTime;
			foreach ( var bone in pose.Bones )
			{
				var track = clip.EnsureTrack( bone.BoneName );
				track.Kind = RigControlKind.Arm;
				WeaponAnimationMath.UpsertKey( track, time, bone.LocalTransform );
			}
			clip.Readiness = clip.Role == WeaponClipRole.Idle
				? ClipReadiness.Ready
				: ClipReadiness.Draft;
			document.Binding.DefaultGripPoseId = pose.Id;
		} );
	}

	private void AddConstraint()
	{
		var clip = _controller.Document.GetSelectedClip();
		var source = _controller.Document.Workspace.SelectedControl;
		var target = _controller.Document.Workspace.ConstraintTargetBone;
		if ( clip is null || string.IsNullOrWhiteSpace( source ) || string.IsNullOrWhiteSpace( target ) )
		{
			StatusChanged?.Invoke(
				"Select an arm control and a weapon bone before adding a constraint.",
				ValidationSeverity.Warning );
			return;
		}

		_controller.Mutate( "Add timed constraint", _ => clip.Constraints.Add( new TimedConstraint
		{
			SourceControl = source,
			TargetBone = target,
			StartTime = _controller.Document.Workspace.TimelineTime,
			EndTime = clip.Duration
		} ) );
	}

	private void ShowConstraintTargetMenu()
	{
		var menu = new Menu( this );
		var weaponBones = _controller.Document.Rig.RetainedBones()
			.OrderBy( x => x.Name );
		foreach ( var bone in weaponBones )
		{
			var captured = bone.Name;
			menu.AddOption( captured, null, () => _controller.Mutate(
				"Constraint target",
				d => d.Workspace.ConstraintTargetBone = captured ) );
		}
		menu.OpenAtCursor();
	}

	private void AddTag( string name, AnimationTagKind kind )
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null || string.IsNullOrWhiteSpace( name ) )
			return;
		_controller.Mutate( $"Add tag {name}", document =>
		{
			var start = document.Workspace.TimelineTime;
			clip.Tags.Add( new AnimationTag
			{
				Name = name.Trim(),
				Kind = kind,
				StartTime = start,
				EndTime = kind == AnimationTagKind.Range
					? MathF.Min( start + 0.1f, clip.Duration )
					: start
			} );
		} );
	}

	private void SimulateParameter( string name, WeaponClipRole role )
	{
		var clip = _controller.Document.Clips.FirstOrDefault( x => x.Role == role );
		if ( clip is null )
			return;
		_controller.Document.Graph.PreviewBools[name] = true;
		_controller.SelectClip( clip.Id );
		StatusChanged?.Invoke(
			$"Simulating {name}=true with {(clip.Readiness == ClipReadiness.NotStarted ? "Idle fallback" : clip.Name)}.",
			clip.Readiness == ClipReadiness.NotStarted ? ValidationSeverity.Warning : ValidationSeverity.Info );
	}

	private string SelectionName()
	{
		var workspace = _controller.Document.Workspace;
		if ( !string.IsNullOrWhiteSpace( workspace.SelectedControl ) )
			return workspace.SelectedControl.TrimStart( '@' ).Replace( '_', ' ' );
		if ( !string.IsNullOrWhiteSpace( workspace.SelectedBone ) )
			return workspace.SelectedBone;
		return "No control selected";
	}

	private RigTarget? ResolveControl( string name ) => name switch
	{
		"@primary_hand" => _controller.Document.Binding.PrimaryHand,
		"@support_hand" => _controller.Document.Binding.SupportHand,
		"@primary_elbow" => _controller.Document.Binding.PrimaryElbowPole,
		"@support_elbow" => _controller.Document.Binding.SupportElbowPole,
		_ => null
	};

	private Widget NumericField(
		string name,
		float value,
		Action<float> changed,
		Widget? parent = null )
	{
		parent ??= _canvas;
		var row = RigAuditPanel.Row( parent );
		row.Layout.Add( WeaponAnimatorTheme.Label( name, row, true ), 1 );
		var edit = new LineEdit( row )
		{
			Text = value.ToString( "0.###", CultureInfo.InvariantCulture ),
			FixedHeight = 26,
			FixedWidth = 86
		};
		edit.SetStyles( WeaponAnimatorTheme.InputStyle );
		edit.EditingFinished += () =>
		{
			if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )
				changed( parsed );
		};
		row.Layout.Add( edit );
		return row;
	}

	private Button ChoiceButton(
		string label,
		Func<string> current,
		IEnumerable<string> values,
		Action<string> changed,
		Widget? parent = null )
	{
		parent ??= _canvas;
		var button = new WeaponAnimatorButton( $"{label}: {current()}", "expand_more", parent )
		{
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Clicked = () =>
		{
			var menu = new Menu( button );
			foreach ( var value in values )
			{
				var captured = value;
				menu.AddOption( captured, null, () =>
				{
					changed( captured );
					button.Text = $"{label}: {current()}";
					button.FitToContent();
				} );
			}
			menu.OpenAt( button.ScreenRect.BottomLeft );
		};
		return button;
	}

	private static Button ToggleButton(
		string text,
		bool value,
		Action<bool> changed,
		Widget parent )
	{
		var button = new WeaponAnimatorButton( text, parent )
		{
			IsToggle = true,
			IsChecked = value,
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Toggled = () => changed( button.IsChecked );
		return button;
	}

	private Label Header( string text )
	{
		return WeaponAnimatorTheme.SectionLabel( text, _canvas, topMargin: true );
	}

	private static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>
		clip.Tracks.GroupBy( x => x.Interpolation )
			.OrderByDescending( x => x.Count() )
			.Select( x => x.Key )
			.FirstOrDefault();
}

public sealed class AnimationTimelinePanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly TimelineEditorCanvas _timeline;
	private readonly TimelineControlToolbar _toolbar;
	private readonly Label _timeLabel;
	private readonly WeaponAnimatorButton _playButton;
	private readonly WeaponAnimatorButton _curvesButton;
	private readonly WeaponAnimatorButton _loopButton;

	public AnimationTimelinePanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		_toolbar = new TimelineControlToolbar( this );
		var left = _toolbar.LeftSection;
		left.Layout.Add( CompactAction( "Add key", "key", AddKey, left, true ) );
		left.Layout.Add( CompactAction( "Copy", "content_copy", _controller.CopySelectedKeys, left ) );
		left.Layout.Add( CompactAction( "Paste", "content_paste", _controller.PasteKeys, left ) );
		left.Layout.Add( CompactAction( "Delete", "delete", _controller.DeleteSelectedKeys, left ) );
		var reverse = CompactAction( "Reverse", "swap_horiz", _controller.ReverseKeys, left );
		reverse.ToolTip =
			"Reverse selected keys within their time range. With no selection, reverse the whole clip.";
		left.Layout.Add( reverse );
		_curvesButton = CompactAction(
			"Curves",
			"show_chart",
			() => _controller.SetCurveEditorVisible(
				!_controller.Document.Workspace.CurveEditorVisible ),
			left );
		_curvesButton.IsToggle = true;
		left.Layout.Add( _curvesButton );

		var player = _toolbar.CenterSection;
		player.Layout.Spacing = 3;
		player.Layout.Add( new Widget( player )
		{
			FixedWidth = 28,
			MinimumWidth = 28,
			FixedHeight = 26
		} );
		player.Layout.Add( PlayerButton( "first_page", "Jump to first frame", _controller.JumpToFirstFrame, player ) );
		player.Layout.Add( PlayerButton( "skip_previous", "Previous frame", () => _controller.StepTimelineFrame( -1 ), player ) );
		_playButton = PlayerButton( "play_arrow", "Play", _controller.TogglePlayback, player );
		player.Layout.Add( _playButton );
		player.Layout.Add( PlayerButton( "skip_next", "Next frame", () => _controller.StepTimelineFrame( 1 ), player ) );
		player.Layout.Add( PlayerButton( "last_page", "Jump to last frame", _controller.JumpToLastFrame, player ) );
		_loopButton = PlayerButton(
			"repeat",
			"Loop playback",
			_controller.ToggleSelectedClipLoop,
			player );
		_loopButton.IsToggle = true;
		_loopButton.Flat = true;
		player.Layout.Add( _loopButton );

		var right = _toolbar.RightSection;
		right.Layout.AddStretchCell();
		_timeLabel = WeaponAnimatorTheme.Label( "", right );
		right.Layout.Add( _timeLabel );
		_toolbar.FitSections();
		Layout.Add( _toolbar );

		_timeline = new TimelineEditorCanvas( controller, this );
		Layout.Add( _timeline, 1 );
		_controller.DocumentChanged += Refresh;
		_controller.TimelineChanged += Refresh;
		_controller.TimelineViewChanged += Refresh;
		_controller.PlaybackChanged += Refresh;
		_controller.ClipPlaybackSettingsChanged += Refresh;
		Refresh();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Refresh;
		_controller.TimelineChanged -= Refresh;
		_controller.TimelineViewChanged -= Refresh;
		_controller.PlaybackChanged -= Refresh;
		_controller.ClipPlaybackSettingsChanged -= Refresh;
		base.OnDestroyed();
	}

	private void AddKey()
		=> _controller.KeySelectedTransform();

	private void Refresh()
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null )
			_timeLabel.Text = "No clip";
		else
		{
			var frame = TimelineInteraction.TimeToFrame(
				_controller.Document.Workspace.TimelineTime,
				clip.SampleRate );
			var total = TimelineInteraction.LastFrame( clip );
			_timeLabel.Text =
				$"{_controller.Document.Workspace.TimelineTime:0.000}s · {frame:00} / {total:00}";
		}
		_playButton.Icon = _controller.IsPlaying ? "pause" : "play_arrow";
		_playButton.ToolTip = _controller.IsPlaying ? "Pause" : "Play";
		_loopButton.Enabled = clip is not null;
		_loopButton.IsChecked = clip?.Loop == true;
		_loopButton.Tint = clip?.Loop == true
			? WeaponAnimatorTheme.Cyan
			: WeaponAnimatorTheme.Muted;
		_loopButton.ToolTip = clip?.Loop == true
			? "Loop playback is enabled"
			: "Loop playback";
		var curves = _controller.Document.Workspace.CurveEditorVisible;
		_curvesButton.IsChecked = curves;
		_curvesButton.Text = curves ? "Keys" : "Curves";
		_curvesButton.Icon = curves ? "view_timeline" : "show_chart";
		_curvesButton.Tint = curves
			? WeaponAnimatorTheme.Cyan * 0.65f
			: WeaponAnimatorTheme.SurfaceRaised;
		_curvesButton.ToolTip = curves
			? "Return to the keyframe view"
			: "Open the curve editor";
		_curvesButton.FitToContent( true );
		_toolbar.FitSections();
		_timeline.Update();
	}

	private static WeaponAnimatorButton CompactAction(
		string text,
		string icon,
		Action clicked,
		Widget parent,
		bool primary = false )
	{
		var button = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(
			text,
			icon,
			clicked,
			parent,
			primary );
		button.FixedHeight = 26;
		button.FitToContent( true );
		return button;
	}

	private static WeaponAnimatorButton PlayerButton(
		string icon,
		string tooltip,
		Action clicked,
		Widget parent )
	{
		var button = new WeaponAnimatorButton( "", icon, parent )
		{
			Clicked = clicked,
			FixedWidth = 28,
			FixedHeight = 26,
			Tint = WeaponAnimatorTheme.SurfaceRaised,
			ToolTip = tooltip
		};
		return button;
	}
}

internal sealed class TimelineControlToolbar : Widget
{
	public Widget LeftSection { get; }
	public Widget CenterSection { get; }
	public Widget RightSection { get; }

	public TimelineControlToolbar( Widget? parent = null ) : base( parent )
	{
		FixedHeight = 34;
		SetStyles( "background-color: rgb(24,27,30); border: none;" );
		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 7, 4, 7, 4 );
		Layout.Spacing = 0;

		LeftSection = Section( this );
		CenterSection = Section( this );
		RightSection = Section( this );
		Layout.Add( LeftSection );
		Layout.AddStretchCell();
		Layout.Add( RightSection );
		CenterSection.Raise();
	}

	public void FitSections()
	{
		LeftSection.FixedWidth = SectionWidth( LeftSection );
		CenterSection.FixedWidth = SectionWidth( CenterSection );
		PositionCenter();
	}

	protected override void OnResize()
	{
		base.OnResize();
		PositionCenter();
	}

	private void PositionCenter()
	{
		CenterSection.Position = new Vector2(
			CenteredLeft( Width, CenterSection.Width ),
			MathF.Round( (Height - CenterSection.Height) * 0.5f ) );
		CenterSection.Raise();
	}

	internal static float CenteredLeft( float toolbarWidth, float sectionWidth ) =>
		MathF.Round( (toolbarWidth - sectionWidth) * 0.5f );

	private static float SectionWidth( Widget section )
	{
		var children = section.Children.ToArray();
		if ( children.Length == 0 )
			return 0;
		return children.Sum( x => x is WeaponAnimatorButton button
			? string.IsNullOrWhiteSpace( button.Text )
				? 28
				: MathF.Ceiling( button.PreferredWidth )
			: MathF.Max( x.MinimumWidth, 0 ) )
			+ MathF.Max( children.Length - 1, 0 ) * section.Layout.Spacing;
	}

	private static Widget Section( Widget parent )
	{
		var section = new Widget( parent )
		{
			Layout = Layout.Row(),
			FixedHeight = 26
		};
		section.SetStyles( "background-color: transparent; border: none;" );
		section.Layout.Margin = 0;
		section.Layout.Spacing = 4;
		return section;
	}
}

internal static class ClipExtensions
{
	public static void KeysClampToDuration( this WeaponAnimationClip clip )
	{
		foreach ( var key in clip.Tracks.SelectMany( x => x.Keys ) )
			key.Time = Math.Clamp( key.Time, 0, clip.Duration );
		foreach ( var key in clip.VisibilityTracks.SelectMany( x => x.Keys ) )
			key.Time = Math.Clamp( key.Time, 0, clip.Duration );
		foreach ( var tag in clip.Tags )
		{
			tag.StartTime = Math.Clamp( tag.StartTime, 0, clip.Duration );
			tag.EndTime = Math.Clamp( tag.EndTime, tag.StartTime, clip.Duration );
		}
	}
}
#nullable enable annotations

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

namespace SboxWeaponAnimator.Editor;

internal readonly record struct GridVisualStyle(
	float MinorOpacity,
	float MajorOpacity,
	float AxisOpacity,
	float MinorWidth,
	float MajorWidth,
	float AxisWidth )
{
	public static GridVisualStyle Resolve( float opacity, float lineWeight )
	{
		var alpha = Math.Clamp( opacity, 0, 0.5f );
		var weight = Math.Clamp( lineWeight, 0.1f, 2.0f );
		return new GridVisualStyle(
			alpha * 0.42f,
			alpha * 0.70f,
			alpha,
			weight * 0.38f,
			weight * 0.58f,
			weight * 0.78f );
	}
}

internal readonly record struct ViewportRimLightStyle(
	bool Enabled,
	float Intensity,
	Color Color )
{
	public static ViewportRimLightStyle Resolve(
		bool enabled,
		float intensity,
		bool fullBright )
	{
		var safeIntensity = WeaponAnimationMath.IsFinite( intensity )
			? Math.Clamp( intensity, 0, 12 )
			: 4.0f;
		return new ViewportRimLightStyle(
			enabled && !fullBright && safeIntensity > 0.001f,
			safeIntensity,
			WeaponAnimatorTheme.Cyan * safeIntensity );
	}
}

internal enum SkeletonBoneKind
{
	Weapon,
	Arm,
	Twist,
	Ik
}

/// <summary>
/// <paramref name="Hollow"/> draws the bone as a wireframe orb instead of a filled dot. Solid means
/// "you pose this directly"; hollow means the bone is derived - driven by a constraint or kept only
/// as an export helper. Shape reads at a glance where a size difference alone does not.
/// </summary>
internal readonly record struct SkeletonBoneStyle(
	bool Visible,
	Color Color,
	float AlphaScale,
	float RadiusScale,
	bool Hollow )
{
	/// <summary>
	/// The Facepunch arms ship four IK helper bones (`hand_*_to_*_ikrule`) kept through compilation
	/// by BoneMarkup even though they skin nothing, and the host builder adds `ik_hand_R`/`ik_hand_L`
	/// parented to weapon_root. Nothing reads any of them, and their default binding offset puts
	/// them well in front of the weapon, so they trail long lines across the viewport.
	/// </summary>
	public static SkeletonBoneKind Classify( HostBone bone )
	{
		// Checked ahead of the weapon test on purpose: weapon rigs commonly ship their own IK
		// targets (weapon_IK_hand_R), and those are helpers whichever rig they arrived from.
		// Hiding is display-only, so a false positive costs visibility, never generated output.
		if ( HasIkToken( bone.Name ) )
			return SkeletonBoneKind.Ik;
		if ( bone.IsWeaponBone )
			return SkeletonBoneKind.Weapon;

		// Twist bones deform the mesh, so they stay visible and clickable - just quieter.
		return bone.Name.Contains( "_twist", StringComparison.OrdinalIgnoreCase )
			? SkeletonBoneKind.Twist
			: SkeletonBoneKind.Arm;
	}

	/// <summary>
	/// Matches `ik` and `ikrule` as whole underscore-delimited tokens rather than as substrings, so
	/// `weapon_IK_hand_R` and `hand_R_to_weapon_ikrule` are caught while ordinary names that merely
	/// contain the letters - `spike`, `strike_plate` - are not.
	/// </summary>
	private static bool HasIkToken( string name )
	{
		foreach ( var token in name.Split( '_', StringSplitOptions.RemoveEmptyEntries ) )
		{
			if ( token.Equals( "ik", StringComparison.OrdinalIgnoreCase )
				|| token.Equals( "ikrule", StringComparison.OrdinalIgnoreCase ) )
				return true;
		}
		return false;
	}

	public static SkeletonBoneStyle Resolve(
		SkeletonBoneKind kind,
		int depth,
		int maxDepth,
		bool showIk )
	{
		if ( kind == SkeletonBoneKind.Weapon )
			return new SkeletonBoneStyle( true, WeaponAnimatorTheme.Amber, 1.0f, 1.0f, false );
		if ( kind == SkeletonBoneKind.Ik )
			return new SkeletonBoneStyle( showIk, WeaponAnimatorTheme.Coral, 1.0f, 1.0f, true );

		var fraction = maxDepth > 0
			? Math.Clamp( depth / (float)maxDepth, 0, 1 )
			: 0;
		var color = WeaponAnimatorTheme.BoneDepthColor( fraction );

		// Twist bones are driven by TiltTwist constraints, so they read as hollow. They keep close
		// to full size because a wireframe orb needs the room to be legible at all.
		return kind == SkeletonBoneKind.Twist
			? new SkeletonBoneStyle( true, color, 0.7f, 0.9f, true )
			: new SkeletonBoneStyle( true, color, 1.0f, 1.0f, false );
	}
}

internal readonly record struct SkeletonOverlayStyle(
	bool DrawThroughMeshes,
	float VisibleAlpha,
	float OccludedAlpha )
{
	/// <summary>
	/// Occluded bones use smaller marks so they stay readable without competing with visible bones.
	/// </summary>
	public const float OccludedDotScale = 0.55f;
	public const float OccludedLineThickness = 0.6f;
	public const int OcclusionGradientSegments = 10;

	/// <summary>
	/// Pushes an occluded bone most of the way to grey. Hue carries depth along the arm chain, so
	/// draining it is what makes "behind something" read as a different category rather than just a
	/// dimmer version of the same thing. A little colour is left so weapon and arm stay tellable.
	/// </summary>
	public static Color Occlude( Color color )
	{
		var luminance = (color.r * 0.299f) + (color.g * 0.587f) + (color.b * 0.114f);
		return Color.Lerp(
			color,
			new Color( luminance, luminance, luminance, color.a ),
			0.8f );
	}

	public static float OcclusionDepthClearance( float cameraDistance )
	{
		var safeDistance = WeaponAnimationMath.IsFinite( cameraDistance )
			? MathF.Max( cameraDistance, 0 )
			: 0;
		var markerRadius = Math.Clamp( safeDistance / 180.0f, 0.08f, 0.45f );
		return MathF.Max( markerRadius * 0.35f, 0.05f );
	}

	public static bool IsOccludingDepth(
		float targetDistance,
		float hitDistance )
	{
		return WeaponAnimationMath.IsFinite( targetDistance )
			&& WeaponAnimationMath.IsFinite( hitDistance )
			&& targetDistance - hitDistance > OcclusionDepthClearance( targetDistance );
	}

	public static SkeletonOverlayStyle Resolve( bool xray, float baseAlpha )
	{
		var safe = WeaponAnimationMath.IsFinite( baseAlpha )
			? Math.Clamp( baseAlpha, 0, 1 )
			: 1.0f;
		return new SkeletonOverlayStyle(
			xray && safe > 0.001f,
			safe,
			safe * 0.28f );
	}

	public SkeletonLineVisual ResolveLineVisual(
		SkeletonBoneStyle bone,
		bool occluded )
	{
		var color = occluded ? Occlude( bone.Color ) : bone.Color;
		var alpha = (occluded ? OccludedAlpha : VisibleAlpha)
			* bone.AlphaScale
			* 0.45f;
		return new SkeletonLineVisual(
			color.WithAlpha( alpha ),
			occluded ? OccludedLineThickness : 1.0f );
	}
}

internal readonly record struct SkeletonLineVisual(
	Color Color,
	float Thickness )
{
	public static SkeletonLineVisual Lerp(
		SkeletonLineVisual start,
		SkeletonLineVisual end,
		float fraction )
	{
		var t = WeaponAnimationMath.IsFinite( fraction )
			? Math.Clamp( fraction, 0, 1 )
			: 0;
		return new SkeletonLineVisual(
			Color.Lerp( start.Color, end.Color, t ),
			start.Thickness + ((end.Thickness - start.Thickness) * t) );
	}
}

internal static class SkeletonOcclusionPolicy
{
	public static bool IsOccludedByArm(
		bool targetIsWeaponBone,
		int targetArmSide,
		int hitArmSide )
	{
		return hitArmSide != 0
			&& (targetIsWeaponBone
				|| (targetArmSide != 0 && targetArmSide != hitArmSide));
	}
}

internal readonly record struct ArmPreviewVisualStyle(
	bool UseFlatMaterial,
	Color Tint )
{
	public static ArmPreviewVisualStyle Resolve(
		WeaponAnimatorStage stage,
		bool fullBright )
	{
		if ( fullBright )
		{
			return new ArmPreviewVisualStyle(
				true,
				new Color( 0.78f, 0.55f, 0.43f ) );
		}

		return stage == WeaponAnimatorStage.Animate
			? new ArmPreviewVisualStyle( false, Color.White )
			: new ArmPreviewVisualStyle(
				false,
				new Color( 0.42f, 0.84f, 0.92f, 0.42f ) );
	}
}

public enum WeaponAnimatorTransformMode
{
	Move,
	Rotate,
	Scale
}

internal sealed class RotationSnapStepWidget : Widget
{
	private readonly LineEdit _edit;
	private readonly Func<float> _getValue;
	private readonly Action<float> _setValue;

	public RotationSnapStepWidget(
		Func<float> getValue,
		Action<float> setValue,
		Widget parent ) : base( parent )
	{
		_getValue = getValue;
		_setValue = setValue;
		FixedWidth = 55;
		FixedHeight = 28;
		ToolTip = "Rotation snap angle";
		SetStyles(
			"background-color: rgb(20,23,26);" +
			"border: 1px solid rgba(255,255,255,0.09);" +
			"border-radius: 3px;" );
		Layout = Layout.Row();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		_edit = new LineEdit( this )
		{
			FixedHeight = 26,
			ToolTip = ToolTip
		};
		_edit.SetStyles(
			"background-color: transparent; border: none;" +
			"color: rgb(224,229,234); font-size: 11px;" +
			"text-align: right; padding: 0 1px 0 2px;" );
		_edit.TextEdited += ApplyText;
		_edit.EditingFinished += Refresh;
		Layout.Add( _edit, 1 );

		var suffix = WeaponAnimatorTheme.Label( "°", this );
		suffix.FixedWidth = 9;
		suffix.Alignment = TextFlag.Center;
		Layout.Add( suffix );

		var buttons = Layout.AddColumn();
		buttons.Add( new IconButton( "keyboard_arrow_up", () => Step( 1 ) )
		{
			Background = Color.Transparent,
			FixedWidth = 16,
			FixedHeight = 13,
			IconSize = 12,
			ToolTip = "Increase rotation snap angle"
		} );
		buttons.Add( new IconButton( "keyboard_arrow_down", () => Step( -1 ) )
		{
			Background = Color.Transparent,
			FixedWidth = 16,
			FixedHeight = 13,
			IconSize = 12,
			ToolTip = "Decrease rotation snap angle"
		} );
		Refresh();
	}

	public void Refresh()
	{
		if ( _edit.IsFocused )
			return;

		_edit.Text = _getValue().ToString( "0.##", CultureInfo.InvariantCulture );
		_edit.CursorPosition = 0;
		Update();
	}

	private void ApplyText( string text )
	{
		if ( float.TryParse(
			text,
			NumberStyles.Float,
			CultureInfo.InvariantCulture,
			out var value )
			&& WeaponAnimationMath.IsFinite( value ) )
			_setValue( value );
	}

	private void Step( int direction )
	{
		_edit.Blur();
		_setValue( WeaponAnimatorViewport.AdjustRotationSnapAngle(
			_getValue(),
			direction ) );
		Refresh();
	}
}

public sealed class WeaponAnimatorViewport : SceneRenderingWidget
{
	private const int LegacyIdleRepairVersion = 3;
	private const float ScaleGizmoSensitivity = 0.005f;
	private const string ArmsOccluderTag = "weaponanim_arms_occluder";
	private static readonly float[] RotationSnapSteps =
		[0.25f, 0.5f, 1, 5, 15, 30, 45, 90, 180];
	private Rect TransformReadoutRect =>
		new( 260, Width < 620 ? 46 : 10, 104, 28 );
	private readonly WeaponAnimatorController _controller;
	private readonly CameraComponent _camera;
	private readonly PointLight _rimLight;
	private readonly Material _flatArmsMaterial;
	private readonly WeaponAnimatorButton _moveModeButton;
	private readonly WeaponAnimatorButton _rotateModeButton;
	private readonly WeaponAnimatorButton _scaleModeButton;
	private readonly WeaponAnimatorButton _spaceButton;
	private readonly WeaponAnimatorButton _rotationSnapButton;
	private readonly RotationSnapStepWidget _rotationSnapStep;
	private readonly WeaponAnimatorButton _orbitCameraButton;
	private readonly WeaponAnimatorButton _freeLookCameraButton;
	private readonly WeaponAnimatorButton _lightingButton;
	private string _transformModeText = "";
	private SkinnedModelRenderer? _sourceRenderer;
	private SkinnedModelRenderer? _armsRenderer;
	private ModelHitboxes? _armsHitboxes;
	private SkinnedModelRenderer? _hostRenderer;
	private HostSkeleton? _hostSkeleton;
	private HostSkeleton? _boneDepthSource;
	private readonly Dictionary<string, int> _boneDepths =
		new( StringComparer.OrdinalIgnoreCase );
	private readonly Dictionary<string, Transform> _occlusionPose =
		new( StringComparer.OrdinalIgnoreCase );
	private readonly HashSet<string> _occludedBones =
		new( StringComparer.OrdinalIgnoreCase );
	private Transform _occlusionCameraTransform;
	private bool _occlusionCacheValid;
	private bool _occlusionFollowupPending;
	private string _lastOcclusionDiagnostic = "";
	private RealTimeSince _sinceOcclusionTrace = 99;
	private int _maxBoneDepth;
	private string _loadedSource = "";
	private string _loadedHost = "";
	private string _lastDiagnosticSelection = "";
	private int _legacyIdleRepairVersionChecked;
	private bool _sourcePoseDiagnosticsLogged;
	private int _sourcePoseDiagnosticFrames;
	private bool _armPoseDiagnosticsLogged;
	private int _armPoseDiagnosticFrames;
	private Vector2 _lastMouse;
	private string _calibrationGizmoTarget = "";
	private Transform _calibrationGizmoStartWorld;
	private Transform _calibrationGizmoStartLocal;
	private Vector3 _calibrationGizmoMoveDelta;
	private Vector3 _calibrationGizmoScaleDelta;
	private string _animationGizmoTarget = "";
	private RigControlKind _animationGizmoKind;
	private Transform _animationGizmoStartLocal;
	private Transform _animationGizmoStartWorld;
	private Transform? _animationGizmoStartParent;
	private Vector3 _animationGizmoMoveDelta;
	private Vector3 _animationGizmoScaleDelta;
	private RealTimeSince _sinceCameraSpeedChanged = 99;

	public ViewportPickMode PickMode { get; set; }

	/// <summary>
	/// Which custom anchor a <see cref="ViewportPickMode.CustomAnchor"/> pick will place.
	/// </summary>
	public Guid PickAnchorId { get; set; }
	public bool IsPlaying => _controller.IsPlaying;
	public WeaponAnimatorTransformMode TransformMode { get; private set; }
	public Vector3 ModelDimensions => _sourceRenderer?.Model?.Bounds.Size ?? Vector3.Zero;
	public bool ConsumesFreeLookMovementShortcut =>
		_controller.Document.Workspace.FreeLookCamera
		&& !_controller.Document.Workspace.FirstPersonPreview
		&& IsActiveWindow
		&& IsUnderMouse
		&& PickMode == ViewportPickMode.None;
	public event Action<string>? StatusChanged;
	public event Action<Vector3>? ModelDimensionsChanged;
	public event Action? LegacyIdleRepaired;

	public WeaponAnimatorViewport(
		WeaponAnimatorController controller,
		Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		MinimumSize = new Vector2( 420, 280 );
		FocusMode = FocusMode.Click;
		MouseTracking = true;
		Scene = Scene.CreateEditorScene();

		using ( Scene.Push() )
		{
			_camera = new GameObject( true, "weapon_animator_camera" )
				.GetOrAddComponent<CameraComponent>( false );
			_camera.BackgroundColor = WeaponAnimatorTheme.Background;
			_camera.ZNear = 0.5f;
			_camera.ZFar = 8192;
			_camera.Enabled = true;
			Camera = _camera;

			var ambient = new GameObject( true, "ambient" )
				.GetOrAddComponent<AmbientLight>( false );
			ambient.Color = new Color( 0.26f, 0.29f, 0.33f );
			ambient.Enabled = true;

			var key = new GameObject( true, "key_light" )
				.GetOrAddComponent<DirectionalLight>( false );
			key.WorldRotation = Rotation.From( 38, 135, 0 );
			key.LightColor = new Color( 1.0f, 0.92f, 0.82f ) * 1.3f;
			key.SkyColor = new Color( 0.18f, 0.22f, 0.27f );
			key.Enabled = true;

			_rimLight = new GameObject( true, "rim_light" )
				.GetOrAddComponent<PointLight>( false );
			_rimLight.WorldPosition = new Vector3( -32, 38, 28 );
			_rimLight.Radius = 160;
		}
		_flatArmsMaterial = Material.Load( "materials/dev/primary_white.vmat" );
		ApplyViewportRenderStyle();

		_moveModeButton = AddTransformModeButton(
			"open_with",
			"Move (W)",
			WeaponAnimatorTransformMode.Move,
			new Vector2( 10, 10 ) );
		_rotateModeButton = AddTransformModeButton(
			"360",
			"Rotate (E)",
			WeaponAnimatorTransformMode.Rotate,
			new Vector2( 41, 10 ) );
		_scaleModeButton = AddTransformModeButton(
			"zoom_out_map",
			"Scale (R)",
			WeaponAnimatorTransformMode.Scale,
			new Vector2( 72, 10 ) );
		_spaceButton = new WeaponAnimatorButton( "", "public", this )
		{
			IsToggle = true,
			Clicked = ToggleTransformSpace,
			Position = new Vector2( 119, 10 ),
			FixedWidth = 28,
			FixedHeight = 28
		};
		_spaceButton.Raise();
		_rotationSnapButton = new WeaponAnimatorButton(
			"",
			"rotate_90_degrees_cw",
			this )
		{
			IsToggle = true,
			Clicked = ToggleRotationSnap,
			Position = new Vector2( 166, 10 ),
			FixedWidth = 28,
			FixedHeight = 28,
			ToolTip = "Toggle rotation snapping"
		};
		_rotationSnapButton.Raise();
		_rotationSnapStep = new RotationSnapStepWidget(
			() => _controller.Document.Workspace.RotationSnapDegrees,
			SetRotationSnapDegrees,
			this )
		{
			Position = new Vector2( 197, 10 )
		};
		_rotationSnapStep.Raise();
		_orbitCameraButton = AddViewportActionButton(
			"360",
			"Orbit camera",
			() => SetCameraMode( false ) );
		_freeLookCameraButton = AddViewportActionButton(
			"videocam",
			"Free look camera — RMB look, WASD move, wheel changes speed, Shift moves faster",
			() => SetCameraMode( true ) );
		_lightingButton = AddViewportActionButton(
			"light_mode",
			"Toggle lit / full bright",
			ToggleViewportLighting );
		PositionViewportActions();

		_controller.DocumentChanged += OnDocumentChanged;
		_controller.PoseChanged += Update;
		_controller.SelectionChanged += OnSelectionChanged;
		_controller.TimelineChanged += Update;
		RefreshTransformOverlay();
		RefreshViewportCameraButtons();
		RebuildPreview();
	}

	protected override void OnResize()
	{
		base.OnResize();
		PositionViewportActions();
	}

	public override void OnDestroyed()
	{
		EndCalibrationGizmoDrag();
		EndAnimationGizmoDrag();
		_controller.DocumentChanged -= OnDocumentChanged;
		_controller.PoseChanged -= Update;
		_controller.SelectionChanged -= OnSelectionChanged;
		_controller.TimelineChanged -= Update;
		ReleasePreviewScene();
		base.OnDestroyed();
	}

	public void ReleasePreviewScene()
	{
		if ( Scene.IsValid() )
			Scene.Destroy();
		Scene = null;
		_sourceRenderer = null;
		_armsRenderer = null;
		_armsHitboxes = null;
		_hostRenderer = null;
		_hostSkeleton = null;
		_occlusionCacheValid = false;
	}

	public void TogglePlayback()
	{
		_controller.TogglePlayback();
	}

	public void StopPlayback()
	{
		_controller.PausePlayback();
	}

	public void SetTransformMode( WeaponAnimatorTransformMode mode )
	{
		if ( TransformMode == mode )
		{
			RefreshTransformOverlay();
			return;
		}

		EndCalibrationGizmoDrag();
		EndAnimationGizmoDrag();
		TransformMode = mode;
		RefreshTransformOverlay();
		StatusChanged?.Invoke( $"{TransformModeName( mode )} gizmo selected." );
		Update();
	}

	private WeaponAnimatorButton AddTransformModeButton(
		string icon,
		string tooltip,
		WeaponAnimatorTransformMode mode,
		Vector2 position )
	{
		var button = new WeaponAnimatorButton( "", icon, this )
		{
			IsToggle = true,
			Clicked = () => SetTransformMode( mode ),
			Position = position,
			FixedWidth = 28,
			FixedHeight = 28,
			ToolTip = tooltip
		};
		button.Raise();
		return button;
	}

	private WeaponAnimatorButton AddViewportActionButton(
		string icon,
		string tooltip,
		Action clicked )
	{
		var button = new WeaponAnimatorButton( "", icon, this )
		{
			IsToggle = true,
			Clicked = clicked,
			FixedWidth = 28,
			FixedHeight = 28,
			ToolTip = tooltip
		};
		button.Raise();
		return button;
	}

	private void PositionViewportActions()
	{
		if ( _lightingButton is null )
			return;

		var right = MathF.Max( Width - 10, 113 );
		_lightingButton.Position = new Vector2( right - 28, 10 );
		_freeLookCameraButton.Position = new Vector2( right - 72, 10 );
		_orbitCameraButton.Position = new Vector2( right - 103, 10 );
	}

	private void SetCameraMode( bool freeLook )
	{
		var workspace = _controller.Document.Workspace;
		if ( workspace.FreeLookCamera == freeLook )
		{
			RefreshViewportCameraButtons();
			return;
		}

		_controller.UpdateWorkspacePreference(
			freeLook ? "Free look camera" : "Orbit camera",
			state =>
			{
				state.FirstPersonPreview = false;
				if ( freeLook )
				{
					state.CameraPosition = _camera.WorldPosition;
				}
				else
				{
					var rotation = Rotation.From( state.CameraAngles );
					state.CameraFocus = state.CameraPosition
						+ rotation.Forward * state.CameraDistance;
				}
				state.FreeLookCamera = freeLook;
			} );
		RefreshViewportCameraButtons();
		UpdateCamera();
	}

	private void ToggleViewportLighting()
	{
		_controller.UpdateWorkspacePreference(
			"Viewport lighting",
			state => state.FullBrightViewport = !state.FullBrightViewport );
		RefreshViewportCameraButtons();
		UpdateCamera();
	}

	private void RefreshViewportCameraButtons()
	{
		var workspace = _controller.Document.Workspace;
		RefreshTransformModeButton(
			_orbitCameraButton,
			!workspace.FreeLookCamera );
		RefreshTransformModeButton(
			_freeLookCameraButton,
			workspace.FreeLookCamera );
		_lightingButton.IsChecked = workspace.FullBrightViewport;
		_lightingButton.Tint = workspace.FullBrightViewport
			? WeaponAnimatorTheme.Amber * 0.55f
			: WeaponAnimatorTheme.SurfaceRaised;
		_lightingButton.ToolTip = workspace.FullBrightViewport
			? "Full bright — click for Lit"
			: "Lit — click for Full bright";
	}

	private void ToggleTransformSpace()
	{
		_controller.Mutate(
			"Transform coordinate space",
			document => document.Workspace.LocalGizmos =
				!document.Workspace.LocalGizmos );
		RefreshTransformOverlay();
	}

	private void ToggleRotationSnap()
	{
		_controller.UpdateWorkspacePreference(
			"Rotation snapping",
			state => state.SnapRotation = !state.SnapRotation );
		RefreshTransformOverlay();
		Update();
	}

	private void SetRotationSnapDegrees( float value )
	{
		if ( !WeaponAnimationMath.IsFinite( value ) )
			return;

		_controller.UpdateWorkspacePreference(
			"Rotation snap angle",
			state => state.RotationSnapDegrees = Math.Clamp( value, 0.25f, 180.0f ) );
		RefreshTransformOverlay();
		Update();
	}

	private void RefreshTransformOverlay()
	{
		var workspace = _controller.Document.Workspace;
		var local = workspace.LocalGizmos;
		RefreshTransformModeButton(
			_moveModeButton,
			TransformMode == WeaponAnimatorTransformMode.Move );
		RefreshTransformModeButton(
			_rotateModeButton,
			TransformMode == WeaponAnimatorTransformMode.Rotate );
		RefreshTransformModeButton(
			_scaleModeButton,
			TransformMode == WeaponAnimatorTransformMode.Scale );
		_spaceButton.IsChecked = !local;
		_spaceButton.Tint = local
			? WeaponAnimatorTheme.SurfaceRaised
			: WeaponAnimatorTheme.Cyan * 0.55f;
		_spaceButton.ToolTip = local
			? "Local space — click for World"
			: "World space — click for Local";
		RefreshTransformModeButton(
			_rotationSnapButton,
			workspace.SnapRotation );
		_rotationSnapStep.Refresh();
		GizmoInstance.Settings.SnapToAngles = workspace.SnapRotation;
		GizmoInstance.Settings.AngleSpacing =
			WeaponAnimationMath.IsFinite( workspace.RotationSnapDegrees )
				? Math.Clamp( workspace.RotationSnapDegrees, 0.25f, 180.0f )
				: 15.0f;
		_transformModeText =
			$"{TransformModeName( TransformMode ).ToUpperInvariant()} · {(local ? "LOCAL" : "WORLD")}";
	}

	private static void RefreshTransformModeButton(
		WeaponAnimatorButton button,
		bool selected )
	{
		button.IsChecked = selected;
		button.Tint = selected
			? WeaponAnimatorTheme.Cyan * 0.55f
			: WeaponAnimatorTheme.SurfaceRaised;
	}

	private static string TransformModeName( WeaponAnimatorTransformMode mode ) =>
		mode switch
		{
			WeaponAnimatorTransformMode.Rotate => "Rotate",
			WeaponAnimatorTransformMode.Scale => "Scale",
			_ => "Move"
		};

	public void SetPickMode( ViewportPickMode mode, Guid anchorId = default )
	{
		PickMode = mode;
		PickAnchorId = anchorId;
		StatusChanged?.Invoke( mode == ViewportPickMode.None
			? "Pick mode cleared."
			: $"Click the weapon surface or a bone to set {PickLabel( mode )}." );
	}

	public void FitCamera()
	{
		var bounds = _sourceRenderer?.Bounds ?? _hostRenderer?.Bounds;
		if ( bounds is null )
			return;

		var workspace = _controller.Document.Workspace;
		workspace.CameraFocus = bounds.Value.Center;
		workspace.CameraDistance =
			MathF.Max( bounds.Value.Size.Length * 1.25f, 12 );
		if ( workspace.FreeLookCamera )
		{
			var rotation = Rotation.From( workspace.CameraAngles );
			workspace.CameraPosition = workspace.CameraFocus
				- rotation.Forward * workspace.CameraDistance;
		}
		UpdateCamera();
	}

	public void RebuildPreview()
	{
		if ( !Scene.IsValid() )
			return;

		_occlusionCacheValid = false;
		using ( Scene.Push() )
		{
			_sourceRenderer?.GameObject.Destroy();
			_armsRenderer?.GameObject.Destroy();
			_hostRenderer?.GameObject.Destroy();
			_sourceRenderer = null;
			_armsRenderer = null;
			_armsHitboxes = null;
			_hostRenderer = null;
			_hostSkeleton = null;
			_loadedSource = "";
			_loadedHost = "";
			_sourcePoseDiagnosticsLogged = false;
			_sourcePoseDiagnosticFrames = 0;
			_armPoseDiagnosticsLogged = false;
			_armPoseDiagnosticFrames = 0;

			var document = _controller.Document;
			if ( !string.IsNullOrWhiteSpace( document.Source.CompiledModelPath ) )
			{
				// Remember failed loads too. Retrying a full scene rebuild every frame creates
				// overlapping renderers while the scene processes deferred destruction.
				_loadedSource = document.Source.CompiledModelPath;
				var sourceModel = Model.Load( document.Source.CompiledModelPath );
				if ( sourceModel is not null && !sourceModel.IsError )
				{
					var sourceObject = new GameObject( true, "source_weapon_preview" );
					_sourceRenderer = sourceObject.GetOrAddComponent<SkinnedModelRenderer>( false );
					_sourceRenderer.Model = sourceModel;
					_sourceRenderer.Enabled = true;
					ModelDimensionsChanged?.Invoke( sourceModel.Bounds.Size );
				}
				else
				{
					Log.Warning(
						$"[Weapon Animator] source preview model is unavailable: "
						+ $"'{document.Source.CompiledModelPath}'. "
						+ "The viewport will wait for a path change or a manual rebuild." );
				}
			}

			var armsModel = Model.Load( HostSkeletonBuilder.ProductionArmsModel );
			if ( armsModel is null || armsModel.IsError )
				armsModel = HostSkeletonBuilder.LoadArmProfile();
			if ( armsModel is not null && !armsModel.IsError )
			{
				var armsObject = new GameObject( true, "facepunch_arms_preview" );
				armsObject.Tags.Add( ArmsOccluderTag );
				_armsRenderer = armsObject.GetOrAddComponent<SkinnedModelRenderer>( false );
				_armsRenderer.Model = armsModel;
				_armsRenderer.Enabled = true;
				_armsRenderer.Tint = new Color( 0.42f, 0.84f, 0.92f, 0.42f );
				_armsHitboxes = armsObject.GetOrAddComponent<ModelHitboxes>( false );
				_armsHitboxes.Renderer = _armsRenderer;
				_armsHitboxes.Target = armsObject;
				_armsHitboxes.Enabled = true;
			}

			if ( document.ActiveStage == WeaponAnimatorStage.Animate
				&& !string.IsNullOrWhiteSpace( document.Source.PreviewHostPath ) )
			{
				// Failed host loads wait for a path change or an explicit rebuild.
				_loadedHost = document.Source.PreviewHostPath;
				var hostModel = Model.Load( document.Source.PreviewHostPath );
				if ( hostModel is not null && !hostModel.IsError )
				{
					_hostRenderer = new GameObject( true, "animation_host_preview" )
						.GetOrAddComponent<SkinnedModelRenderer>( false );
					_hostRenderer.Model = hostModel;
					_hostRenderer.Enabled = true;
					_hostRenderer.UseAnimGraph = false;
					SuppressHostRendering();
					_hostSkeleton = HostSkeletonBuilder.BuildCached( document );

					if ( _sourceRenderer.IsValid() )
					{
						_sourceRenderer!.WorldTransform = Transform.Zero;
						_sourceRenderer.BoneMergeTarget = null;
					}
					if ( _armsRenderer.IsValid() )
					{
						_armsRenderer!.WorldTransform = Transform.Zero;
						_armsRenderer.BoneMergeTarget = null;
						_armsRenderer.Tint = Color.White;
					}
				}
				else
				{
					Log.Warning(
						$"[Weapon Animator] animation host preview is unavailable: "
						+ $"'{document.Source.PreviewHostPath}'. "
						+ "The viewport will wait for a path change or a manual rebuild." );
				}
			}
		}

		if ( _controller.Document.Workspace.CameraDistance <= 0 )
			FitCamera();
		Update();
	}

	protected override void PreFrame()
	{
		Scene.EditorTick( RealTime.Now, RealTime.Delta );
		GizmoInstance.Input.IsHovered = IsActiveWindow && IsUnderMouse;
		UpdateGizmoInputs( GizmoInstance.Input.IsHovered );
		FinishCalibrationGizmoDragIfReleased();
		FinishAnimationGizmoDragIfReleased();

		if ( RepairLegacyIdleIfNeeded() )
			return;
		EnsurePreviewCurrent();
		AdvancePlayback();
		UpdateFreeLookMovement();
		UpdateCamera();
		ApplyViewportRenderStyle();

		DrawWorkspaceGrid();
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
			DrawCalibration();
		else
			DrawAnimation();

		DrawScreenGuides();
		DrawViewportToolReadout();
		DrawCameraSpeedOverlay();
		Cursor = Gizmo.HasHovered || PickMode != ViewportPickMode.None
			? CursorShape.Finger
			: _controller.Document.Workspace.FreeLookCamera
				&& global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Right )
				&& IsUnderMouse
					? CursorShape.Blank
					: CursorShape.Arrow;
	}

	private void DrawWorkspaceGrid()
	{
		var style = GridVisualStyle.Resolve(
			_controller.Document.Workspace.GridOpacity,
			_controller.Document.Workspace.GridLineThickness );
		if ( style.AxisOpacity <= 0 )
			return;

		var spacing = MathF.Max( Gizmo.Settings.GridSpacing, 1 );
		var desiredExtent = MathF.Max(
			128,
			_controller.Document.Workspace.CameraDistance * 6 );
		var halfLines = Math.Clamp(
			(int)MathF.Ceiling( desiredExtent / spacing ),
			8,
			64 );
		var extent = halfLines * spacing;

		using var scope = Gizmo.Scope( "weapon_animator_grid" );
		for ( var index = -halfLines; index <= halfLines; index++ )
		{
			if ( index == 0 )
				continue;

			var coordinate = index * spacing;
			var major = index % 4 == 0;
			Gizmo.Draw.Color = Color.White.WithAlpha(
				major ? style.MajorOpacity : style.MinorOpacity );
			Gizmo.Draw.LineThickness = major ? style.MajorWidth : style.MinorWidth;
			Gizmo.Draw.Line(
				new Vector3( coordinate, -extent, 0 ),
				new Vector3( coordinate, extent, 0 ) );
			Gizmo.Draw.Line(
				new Vector3( -extent, coordinate, 0 ),
				new Vector3( extent, coordinate, 0 ) );
		}

		Gizmo.Draw.LineThickness = style.AxisWidth;
		Gizmo.Draw.Color = new Color( 0.90f, 0.28f, 0.38f ).WithAlpha( style.AxisOpacity );
		Gizmo.Draw.Line( new Vector3( -extent, 0, 0 ), new Vector3( extent, 0, 0 ) );
		Gizmo.Draw.Color = new Color( 0.58f, 0.78f, 0.20f ).WithAlpha( style.AxisOpacity );
		Gizmo.Draw.Line( new Vector3( 0, -extent, 0 ), new Vector3( 0, extent, 0 ) );
		Gizmo.Draw.LineThickness = 1;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );
		var delta = e.LocalPosition - _lastMouse;
		_lastMouse = e.LocalPosition;
		if ( (e.ButtonState & MouseButtons.Right) == 0
			|| _controller.Document.Workspace.FirstPersonPreview )
			return;

		var workspace = _controller.Document.Workspace;
		workspace.CameraAngles = new Angles(
			Math.Clamp( workspace.CameraAngles.pitch + delta.y * 0.22f, -88, 88 ),
			workspace.CameraAngles.yaw - delta.x * 0.22f,
			0 );
		_controller.MarkWorkspacePreferenceChanged( "Viewport camera rotation" );
		UpdateCamera();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );
		_lastMouse = e.LocalPosition;
		if ( !e.LeftMouseButton || PickMode == ViewportPickMode.None )
			return;

		if ( TryPickSourceSurface( e.LocalPosition, out var localPosition ) )
		{
			ApplyPickedPoint( localPosition );
			e.Accepted = true;
		}
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( _controller.Document.Workspace.FirstPersonPreview )
			return;

		var workspace = _controller.Document.Workspace;
		if ( workspace.FreeLookCamera )
		{
			var direction = Math.Sign( e.Delta );
			_controller.UpdateWorkspacePreference(
				"Free look camera speed",
				state => state.CameraMoveSpeed = AdjustCameraSpeed(
					state.CameraMoveSpeed,
					direction ) );
			_sinceCameraSpeedChanged = 0;
			e.Accept();
			Update();
			return;
		}

		workspace.CameraDistance = Math.Clamp(
			workspace.CameraDistance * (e.Delta > 0 ? 0.9f : 1.1f),
			2,
			4096 );
		_controller.MarkWorkspacePreferenceChanged( "Orbit camera distance" );
		e.Accept();
	}

	private void OnDocumentChanged()
	{
		_occlusionCacheValid = false;
		RefreshTransformOverlay();
		RefreshViewportCameraButtons();
		var document = _controller.Document;
		if ( document.Source.CompiledModelPath != _loadedSource
			|| (document.ActiveStage == WeaponAnimatorStage.Animate
				&& document.Source.PreviewHostPath != _loadedHost)
			|| (document.ActiveStage == WeaponAnimatorStage.Calibrate && _hostRenderer.IsValid()) )
		{
			RebuildPreview();
			return;
		}

		Update();
	}

	private void EnsurePreviewCurrent()
	{
		if ( !Scene.IsValid() )
			return;
		var requestedSource = _controller.Document.Source.CompiledModelPath;
		if ( _sourceRenderer is null
			&& ShouldRetryMissingSourcePreview( requestedSource, _loadedSource ) )
			RebuildPreview();
	}

	internal static bool ShouldRetryMissingSourcePreview(
		string requestedSource,
		string attemptedSource ) =>
		!string.IsNullOrWhiteSpace( requestedSource )
		&& !requestedSource.Equals( attemptedSource, StringComparison.OrdinalIgnoreCase );

	private void AdvancePlayback()
	{
		_controller.AdvancePlayback( RealTime.Delta );
	}

	private void UpdateCamera()
	{
		if ( !_camera.IsValid() )
			return;

		var document = _controller.Document;
		_camera.DebugMode = document.Workspace.FullBrightViewport
			? SceneCameraDebugMode.FullBright
			: SceneCameraDebugMode.Normal;
		if ( document.Workspace.FirstPersonPreview )
		{
			_camera.WorldPosition = Vector3.Zero;
			_camera.WorldRotation = Rotation.Identity;
			var aspect = GuideAspect( document.Calibration.AspectGuide );
			var horizontalRadians = document.Calibration.HorizontalFov.DegreeToRadian();
			_camera.FieldOfView = (2.0f * MathF.Atan(
				MathF.Tan( horizontalRadians * 0.5f ) / aspect )).RadianToDegree();
			return;
		}

		var rotation = Rotation.From( document.Workspace.CameraAngles );
		if ( document.Workspace.FreeLookCamera )
		{
			_camera.WorldPosition = document.Workspace.CameraPosition;
			_camera.WorldRotation = rotation;
			_camera.FieldOfView = 48;
			return;
		}

		var focus = document.Workspace.CameraFocus;
		_camera.WorldPosition = focus - rotation.Forward * document.Workspace.CameraDistance;
		_camera.WorldRotation = Rotation.LookAt( focus - _camera.WorldPosition, Vector3.Up );
		_camera.FieldOfView = 48;
	}

	private void ApplyViewportRenderStyle()
	{
		var document = _controller.Document;
		var rim = ViewportRimLightStyle.Resolve(
			document.Workspace.RimLightEnabled,
			document.Workspace.RimLightIntensity,
			document.Workspace.FullBrightViewport );
		_rimLight.Enabled = rim.Enabled;
		_rimLight.LightColor = rim.Color;

		if ( !_armsRenderer.IsValid() )
			return;
		var arms = ArmPreviewVisualStyle.Resolve(
			document.ActiveStage,
			document.Workspace.FullBrightViewport );
		_armsRenderer!.MaterialOverride = arms.UseFlatMaterial
			? _flatArmsMaterial
			: null;
		_armsRenderer.Tint = arms.Tint;
	}

	private void UpdateFreeLookMovement()
	{
		var workspace = _controller.Document.Workspace;
		if ( !workspace.FreeLookCamera
			|| workspace.FirstPersonPreview
			|| !IsActiveWindow
			|| !IsUnderMouse
			|| PickMode != ViewportPickMode.None
			|| Gizmo.Pressed.Any )
			return;

		var rotation = Rotation.From( workspace.CameraAngles );
		var movement = Vector3.Zero;
		if ( global::Editor.Application.IsKeyDown( KeyCode.W ) )
			movement += rotation.Forward;
		if ( global::Editor.Application.IsKeyDown( KeyCode.S ) )
			movement += rotation.Backward;
		if ( global::Editor.Application.IsKeyDown( KeyCode.A ) )
			movement += rotation.Left;
		if ( global::Editor.Application.IsKeyDown( KeyCode.D ) )
			movement += rotation.Right;
		if ( movement.IsNearZeroLength )
			return;

		var fast = global::Editor.Application.KeyboardModifiers
			.HasFlag( KeyboardModifiers.Shift );
		var speed = workspace.CameraMoveSpeed * 100.0f * (fast ? 8.0f : 1.0f);
		workspace.CameraPosition += movement.Normal * speed * RealTime.Delta;
		_controller.MarkWorkspacePreferenceChanged( "Free look camera position" );
	}

	internal static float AdjustCameraSpeed( float currentSpeed, int direction )
	{
		currentSpeed = Math.Clamp( currentSpeed, 0.25f, 100.0f );
		var adjustment = currentSpeed < 5.0f
			? 0.25f
			: currentSpeed < 20.0f
				? 1.0f
				: MathF.Round( currentSpeed * 0.1f / 2.5f ) * 2.5f;
		return Math.Clamp(
			currentSpeed + adjustment * Math.Sign( direction ),
			0.25f,
			100.0f );
	}

	internal static float AdjustRotationSnapAngle( float currentAngle, int direction )
	{
		if ( !WeaponAnimationMath.IsFinite( currentAngle ) )
			currentAngle = 15;

		var nearest = 0;
		var nearestDistance = float.MaxValue;
		for ( var index = 0; index < RotationSnapSteps.Length; index++ )
		{
			var distance = MathF.Abs( currentAngle - RotationSnapSteps[index] );
			if ( distance >= nearestDistance )
				continue;
			nearest = index;
			nearestDistance = distance;
		}

		var target = Math.Clamp(
			nearest + Math.Sign( direction ),
			0,
			RotationSnapSteps.Length - 1 );
		return RotationSnapSteps[target];
	}

	private void DrawCalibration()
	{
		var document = _controller.Document;
		if ( _sourceRenderer.IsValid() )
		{
			_sourceRenderer!.BoneMergeTarget = null;
			_sourceRenderer.WorldTransform = WeaponAnimationMath.Compose(
				document.Calibration.PhysicalTransform,
				document.Calibration.FramingTransform );
			_sourceRenderer.ClearPhysicsBones();
		}

		if ( _armsRenderer.IsValid() )
		{
			_armsRenderer!.BoneMergeTarget = null;
			_armsRenderer.WorldTransform = Transform.Zero;
		}

		DrawMeasurement();
		DrawAnchors();
		if ( document.Workspace.ShowSkeleton )
			DrawRendererSkeleton( _sourceRenderer, WeaponAnimatorTheme.Amber, allowXray: true );

		// Calibration only ever poses the weapon as a whole, plus its anchors. Selecting a bone
		// no longer suppresses the rig gizmo, which previously left the page with no gizmo at all.
		if ( CalibrationSelection.Resolve( document, document.Workspace.SelectedControl ) is { } anchor )
			DrawSelectedAnchorControl( anchor );
		else
			DrawWholeRigControl();
	}

	private void DrawAnimation()
	{
		if ( !_hostRenderer.IsValid() || _hostSkeleton is null )
			return;

		var document = _controller.Document;
		var clip = document.GetSelectedClip();
		var pose = AnimationPoseEvaluator.Evaluate(
			document,
			_hostSkeleton,
			clip,
			document.Workspace.TimelineTime,
			includeWorkingPose: true );

		_hostRenderer!.ClearPhysicsBones();
		foreach ( var bone in _hostRenderer.Model.Bones.AllBones )
		{
			if ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )
				_hostRenderer.SetBoneTransform( bone, modelTransform );
		}
		SuppressHostRendering();
		ApplyWeaponPoseToSourceRenderer( pose );
		ApplyArmPoseToRenderer( pose );

		document.Binding.PrimaryHand.Reachable = pose.PrimaryReachable;
		document.Binding.SupportHand.Reachable = pose.SupportReachable;
		DrawGripTethers( pose );
		if ( document.Workspace.ShowSkeleton )
			DrawHostSkeleton( pose, 1.0f, useRenderedArms: true, allowXray: true );
		if ( document.Workspace.ShowOnionSkins && clip is not null )
			DrawOnionSkins( clip );
		DrawAnimationControl();
	}

	private void ApplyWeaponPoseToSourceRenderer( EvaluatedPose pose )
	{
		if ( !_sourceRenderer.IsValid() || _hostSkeleton is null )
			return;

		var document = _controller.Document;
		var sourceRoot = document.Rig.FindBone( document.Rig.SourceSkeletonRootId );
		var rootTransform = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );
		if ( sourceRoot is not null
			&& pose.Model.TryGetValue( "weapon_root", out var desiredRootWorld ) )
		{
			rootTransform = WeaponPoseProjection.SolveRendererTransform(
				sourceRoot.BindModelTransform,
				desiredRootWorld );
		}

		_sourceRenderer!.BoneMergeTarget = null;
		_sourceRenderer.WorldTransform = rootTransform;
		_sourceRenderer.ClearPhysicsBones();
		foreach ( var definition in document.Rig.RetainedBones() )
		{
			if ( definition.Id.Equals(
				document.Rig.SourceSkeletonRootId,
				StringComparison.OrdinalIgnoreCase ) )
				continue;

			var sourceBone = _sourceRenderer.Model.Bones.GetBone( definition.Name );
			if ( sourceBone is not null
				&& WeaponPoseProjection.TryGetSourceWorldOverride(
					document,
					pose,
					definition,
					out var transform )
				&& _hostSkeleton.ByName.TryGetValue( definition.Name, out var hostBone )
				&& pose.Local.TryGetValue( definition.Name, out var currentLocal )
				&& !WeaponPoseProjection.TransformNear(
					currentLocal,
					_hostSkeleton.GetBindLocal( hostBone ) ) )
			{
				// Native bind transforms remain untouched; only authored deltas use overrides.
				_sourceRenderer.SetBoneTransform(
					sourceBone,
					_sourceRenderer.WorldTransform.ToLocal( transform ) );
			}
		}
		ApplyPreviewVisibility();
		LogSourcePoseDiagnostics( pose );
	}

	private void ApplyPreviewVisibility()
	{
		if ( !_sourceRenderer.IsValid() )
			return;

		var document = _controller.Document;
		var clip = document.GetSelectedClip();
		foreach ( var part in document.Rig.VisibilityParts )
		{
			var visible = WeaponVisibilityEvaluator.Evaluate(
				part,
				clip,
				document.Workspace.TimelineTime );
			if ( part.RenderMode == VisibilityRenderMode.BodyGroup )
			{
				if ( string.IsNullOrWhiteSpace( part.BodyGroupName )
					|| !_sourceRenderer!.HasBodyGroups )
					continue;
				try
				{
					_sourceRenderer.SetBodyGroup(
						part.BodyGroupName,
						visible
							? part.VisibleBodyGroupValue
							: part.HiddenBodyGroupValue );
				}
				catch ( Exception ex )
				{
					Log.Warning(
						$"[Weapon Animator] preview bodygroup '{part.BodyGroupName}' failed: {ex.Message}" );
				}
				continue;
			}

			if ( !string.IsNullOrWhiteSpace( part.BodyGroupName )
				&& _sourceRenderer!.HasBodyGroups )
			{
				try
				{
					_sourceRenderer.SetBodyGroup(
						part.BodyGroupName,
						part.VisibleBodyGroupValue );
				}
				catch
				{
					// Switching back to bone mode should not leave the old bodygroup hidden.
				}
			}
			if ( visible || string.IsNullOrWhiteSpace( part.BoneName ) )
				continue;
			var root = _sourceRenderer!.Model.Bones.GetBone( part.BoneName );
			if ( root is null )
				continue;

			var collapsed = new Transform(
				Vector3.Down * 4000.0f,
				Rotation.Identity,
				Vector3.One * 0.001f );
			var queue = new Queue<BoneCollection.Bone>();
			queue.Enqueue( root );
			while ( queue.Count > 0 )
			{
				var bone = queue.Dequeue();
				_sourceRenderer.SetBoneTransform( bone, collapsed );
				foreach ( var child in bone.Children )
					queue.Enqueue( child );
			}
		}
	}

	private void ApplyArmPoseToRenderer( EvaluatedPose pose )
	{
		if ( !_armsRenderer.IsValid() )
			return;

		_armsRenderer!.BoneMergeTarget = null;
		_armsRenderer.WorldTransform = Transform.Zero;
		_armsRenderer.ClearPhysicsBones();
		foreach ( var bone in _armsRenderer.Model.Bones.AllBones )
		{
			if ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )
				_armsRenderer.SetBoneTransform( bone, modelTransform );
		}

		LogArmPoseDiagnostics( pose );
	}

	private void LogArmPoseDiagnostics( EvaluatedPose pose )
	{
		if ( _armPoseDiagnosticsLogged || !_armsRenderer.IsValid() )
			return;
		if ( ++_armPoseDiagnosticFrames < 3 )
			return;
		_armPoseDiagnosticsLogged = true;

		var compared = 0;
		var mismatches = 0;
		foreach ( var bone in _armsRenderer!.Model.Bones.AllBones )
		{
			if ( !pose.Model.TryGetValue( bone.Name, out var expected )
				|| !_armsRenderer.TryGetBoneTransform( bone, out var actual ) )
				continue;

			compared++;
			if ( WeaponPoseProjection.TransformNear( expected, actual, 0.001f ) )
				continue;
			mismatches++;
			if ( mismatches <= 4 )
			{
				Log.Warning(
					$"[Weapon Animator] arm pose mismatch '{bone.Name}': "
					+ $"expected={expected}, actual={actual}." );
			}
		}

		Log.Info(
			$"[Weapon Animator] arm pose bridge checked {compared} bones; "
			+ $"{mismatches} renderer override mismatches." );
	}

	private void LogSourcePoseDiagnostics( EvaluatedPose pose )
	{
		if ( _sourcePoseDiagnosticsLogged || !_sourceRenderer.IsValid() )
			return;
		if ( ++_sourcePoseDiagnosticFrames < 3 )
			return;
		_sourcePoseDiagnosticsLogged = true;

		var compared = 0;
		var mismatches = 0;
		var hiddenVisibilityBones = HiddenVisibilityBonesAtPlayhead();
		foreach ( var definition in _controller.Document.Rig.RetainedBones() )
		{
			if ( hiddenVisibilityBones.Contains( definition.Name ) )
				continue;
			var sourceBone = _sourceRenderer!.Model.Bones.GetBone( definition.Name );
			if ( sourceBone is null
				|| !WeaponPoseProjection.TryGetSourceWorldOverride(
					_controller.Document,
					pose,
					definition,
					out var expected )
				|| !_sourceRenderer.TryGetBoneTransform( sourceBone, out var actual ) )
				continue;

			compared++;
			var positionDelta = expected.Position.Distance( actual.Position );
			var rotationDelta = MathF.Max(
				(expected.Rotation.Forward - actual.Rotation.Forward).Length,
				(expected.Rotation.Up - actual.Rotation.Up).Length );
			var scaleDelta = (expected.Scale - actual.Scale).Length;
			if ( positionDelta <= 0.001f
				&& rotationDelta <= 0.001f
				&& scaleDelta <= 0.001f )
				continue;

			mismatches++;
			Log.Warning(
				$"[Weapon Animator] source pose mismatch '{definition.Name}': "
				+ $"position={positionDelta:0.######}, "
				+ $"rotation={rotationDelta:0.######}, "
				+ $"scale={scaleDelta:0.######}; "
				+ $"expected={expected}, actual={actual}." );
		}

		Log.Info(
			$"[Weapon Animator] source pose bridge checked {compared} retained bones; "
			+ $"{mismatches} renderer override mismatches." );
		if ( _hostSkeleton is not null
			&& _hostSkeleton.ByName.TryGetValue( "root", out var hostRoot )
			&& _hostSkeleton.ByName.TryGetValue( "weapon_root", out var weaponRoot )
			&& pose.Model.TryGetValue( "weapon_root", out var rootWorld )
			&& pose.Local.TryGetValue( "weapon_root", out var rootLocal ) )
		{
			Log.Info(
				$"[Weapon Animator] root bridge: hostRootBind={hostRoot.BindModelTransform}, "
				+ $"weaponRootBindModel={weaponRoot.BindModelTransform}, "
				+ $"weaponRootBindLocal={_hostSkeleton.GetBindLocal( weaponRoot )}, "
				+ $"poseRootWorld={rootWorld}, poseRootLocal={rootLocal}, "
				+ $"sourceRenderer={_sourceRenderer!.WorldTransform}." );
		}
	}

	private HashSet<string> HiddenVisibilityBonesAtPlayhead()
	{
		var document = _controller.Document;
		var clip = document.GetSelectedClip();
		var hidden = document.Rig.VisibilityParts
			.Where( x =>
				x.RenderMode == VisibilityRenderMode.BoneBranch
				&& !WeaponVisibilityEvaluator.Evaluate(
					x,
					clip,
					document.Workspace.TimelineTime ) )
			.Select( x => x.BoneName )
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );
		if ( hidden.Count == 0 )
			return hidden;

		var changed = true;
		while ( changed )
		{
			changed = false;
			foreach ( var bone in document.Rig.RetainedBones() )
			{
				if ( hidden.Contains( bone.Name )
					|| !hidden.Contains( bone.ParentName ) )
					continue;
				hidden.Add( bone.Name );
				changed = true;
			}
		}
		return hidden;
	}

	private bool RepairLegacyIdleIfNeeded()
	{
		if ( _legacyIdleRepairVersionChecked == LegacyIdleRepairVersion
			|| _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )
			return false;

		_legacyIdleRepairVersionChecked = LegacyIdleRepairVersion;
		var repaired = false;
		_controller.Mutate(
			"Repair generated Idle bind pose",
			document =>
			{
				var repairedLegacy = WeaponAnimationMigration.RepairLegacyIdleBindPose(
					document,
					_hostSkeleton );
				var repairedSelectionWrites = _hostSkeleton is not null
					&& IdleBindPoseService.RepairUnintendedSelectionWrites(
						document,
						_hostSkeleton );
				repaired = repairedLegacy || repairedSelectionWrites;
			} );
		if ( !repaired )
			return false;

		LegacyIdleRepaired?.Invoke();
		StatusChanged?.Invoke(
			"Restored the generated Idle clip to the current calibrated bind pose. "
			+ "A versioned backup will be created on save." );
		return true;
	}

	private void SuppressHostRendering()
	{
		if ( !_hostRenderer.IsValid() )
			return;

		// The host owns bones only. Its carrier mesh must never enter the authoring viewport.
		_hostRenderer!.Tint = Color.Transparent;
		_hostRenderer.SceneObject.RenderingEnabled = false;
	}

	private void OnSelectionChanged()
	{
		_occlusionCacheValid = false;
		_lastOcclusionDiagnostic = "";
		Update();
		if ( _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )
			return;

		var selected = _controller.Document.Workspace.SelectedBone;
		if ( !selected.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase )
			|| selected.Equals( _lastDiagnosticSelection, StringComparison.OrdinalIgnoreCase ) )
			return;

		_lastDiagnosticSelection = selected;
		var clip = _controller.Document.GetSelectedClip();
		var rootTrack = clip?.Tracks.FirstOrDefault( x =>
			x.Target.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase ) );
		Log.Info(
			$"[Weapon Animator] Preview diagnostic: selected=weapon_root, "
			+ $"sourceModel={_loadedSource}, hostModel={_loadedHost}, "
			+ $"sourceScale={_sourceRenderer?.WorldTransform.Scale}, "
			+ $"hostRendering={_hostRenderer?.SceneObject.RenderingEnabled}, "
			+ $"rootKeys={rootTrack?.Keys.Count ?? 0}, "
			+ $"workingOverride={_controller.Document.Workspace.GetWorkingPose(
				clip?.Id ?? Guid.Empty,
				"weapon_root" ) is not null}." );
	}

	private void DrawRendererSkeleton(
		SkinnedModelRenderer? renderer,
		Color color,
		bool allowXray = false )
	{
		if ( !renderer.IsValid() || renderer!.Model is null )
			return;

		var style = SkeletonOverlayStyle.Resolve(
			allowXray && _controller.Document.Workspace.XRaySkeleton,
			1.0f );

		using ( Gizmo.Scope( "source_skeleton" ) )
		{
			Gizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;
			DrawRendererSkeletonPass( renderer, color, 1.0f );
		}
	}

	private void DrawRendererSkeletonPass(
		SkinnedModelRenderer renderer,
		Color color,
		float alpha )
	{
		foreach ( var bone in renderer.Model.Bones.AllBones )
		{
			if ( !renderer.TryGetBoneTransform( bone, out var transform ) )
				continue;

			if ( bone.Parent is not null
				&& renderer.TryGetBoneTransform( bone.Parent, out var parent ) )
			{
				Gizmo.Draw.Color = color.WithAlpha( 0.55f * alpha );
				Gizmo.Draw.Line( parent.Position, transform.Position );
			}

			using var scope = Gizmo.Scope( $"source_bone:{bone.Name}", transform );
			var selected = bone.Name == _controller.Document.Workspace.SelectedBone;
			var radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 150.0f, 0.1f, 0.7f );
			Gizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( alpha );
			Gizmo.Draw.SolidSphere(
				Vector3.Zero,
				selected ? radius * 0.7f : radius * 0.35f,
				6,
				4 );
			Gizmo.Hitbox.DepthBias = 0.01f;
			Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, radius ) );
			if ( Gizmo.IsHovered )
			{
				Gizmo.Draw.ScreenText( bone.Name, transform.Position, new Vector2( 10, -10 ) );
				if ( Gizmo.WasLeftMousePressed )
					_controller.SelectBone( bone.Name );
			}
		}
	}

	private void DrawHostSkeleton(
		EvaluatedPose pose,
		float alpha,
		bool useRenderedArms = false,
		bool allowXray = false )
	{
		if ( _hostSkeleton is null )
			return;

		EnsureBoneDepths();
		var style = SkeletonOverlayStyle.Resolve(
			allowXray && _controller.Document.Workspace.XRaySkeleton,
			alpha );

		var occludedBones = style.DrawThroughMeshes
			&& _controller.Document.Workspace.BoneOcclusionEnabled
			? ResolveOccludedBones( pose, useRenderedArms )
			: null;
		if ( occludedBones is { Count: > 0 } )
		{
			DrawMixedOcclusionLines(
				pose,
				useRenderedArms,
				occludedBones,
				style );
			using ( Gizmo.Scope( "host_skeleton_behind" ) )
			{
				Gizmo.Draw.IgnoreDepth = true;
				Gizmo.Draw.LineThickness = SkeletonOverlayStyle.OccludedLineThickness;
				DrawHostSkeletonPass(
					pose,
					style.OccludedAlpha,
					useRenderedArms,
					occluded: true,
					onlyBones: occludedBones,
					occlusionStates: occludedBones );
			}
		}

		using ( Gizmo.Scope( "host_skeleton" ) )
		{
			Gizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;
			DrawHostSkeletonPass(
				pose,
				alpha,
				useRenderedArms,
				excludedBones: occludedBones,
				occlusionStates: occludedBones );
		}
	}

	private IReadOnlySet<string> ResolveOccludedBones(
		EvaluatedPose pose,
		bool useRenderedArms )
	{
		var samples = new List<(HostBone Bone, Transform Transform)>();
		var showIk = _controller.Document.Workspace.ShowIkBones;
		foreach ( var bone in _hostSkeleton!.Bones )
		{
			if ((SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Ik && !showIk)
				|| !TryGetDisplayedBoneTransform(
					pose,
					bone,
					useRenderedArms,
					out var transform ) )
				continue;

			samples.Add( (bone, transform) );
		}

		var cacheMatches = OcclusionCacheMatches( samples );
		if ( cacheMatches && !_occlusionFollowupPending )
			return _occludedBones;
		if ( !cacheMatches
			&& _occlusionCacheValid
			&& _sinceOcclusionTrace < (1.0f / 30.0f) )
			return _occludedBones;

		var completingFollowup = cacheMatches && _occlusionFollowupPending;
		_occlusionPose.Clear();
		_occludedBones.Clear();
		_occlusionCameraTransform = _camera.WorldTransform;
		_sinceOcclusionTrace = 0;
		foreach ( var sample in samples )
		{
			_occlusionPose[sample.Bone.Name] = sample.Transform;
			var occluded = IsBoneOccluded(
				sample.Bone,
				sample.Transform.Position,
				out var hitDescription );
			if ( occluded )
				_occludedBones.Add( sample.Bone.Name );

			if ( sample.Bone.Name.Equals(
				_controller.Document.Workspace.SelectedBone,
				StringComparison.OrdinalIgnoreCase ) )
				ReportOcclusionDiagnostic(
					sample.Bone,
					hitDescription,
					occluded );
		}

		_occlusionCacheValid = true;
		_occlusionFollowupPending = !completingFollowup;
		return _occludedBones;
	}

	private bool OcclusionCacheMatches(
		IReadOnlyList<(HostBone Bone, Transform Transform)> samples )
	{
		if ( !_occlusionCacheValid
			|| samples.Count != _occlusionPose.Count
			|| !WeaponPoseProjection.TransformNear(
				_occlusionCameraTransform,
				_camera.WorldTransform,
				0.0005f ) )
			return false;

		foreach ( var sample in samples )
		{
			if ( !_occlusionPose.TryGetValue( sample.Bone.Name, out var cached )
				|| !WeaponPoseProjection.TransformNear(
					cached,
					sample.Transform,
					0.0005f ) )
				return false;
		}

		return true;
	}

	private bool IsBoneOccluded(
		HostBone target,
		Vector3 targetPosition,
		out string description )
	{
		description = "none";
		if ( !Scene.IsValid()
			|| targetPosition.Distance( _camera.WorldPosition ) <= 0.001f )
			return false;

		var targetDistance = targetPosition.Distance( _camera.WorldPosition );
		var depthClearance = SkeletonOverlayStyle.OcclusionDepthClearance( targetDistance );
		var nearestDistance = float.MaxValue;
		var sameArmHits = 0;
		var oppositeArmHits = 0;
		var nearArmHits = 0;
		var unknownArmHits = 0;
		var sameArmExample = "";
		var unknownArmExample = "";

		var armTraces = Scene.Trace
			.Ray( _camera.WorldPosition, targetPosition )
			.WithTag( ArmsOccluderTag )
			.UseRenderMeshes( false )
			.UseHitboxes( true )
			.UsePhysicsWorld( false )
			.UseHitPosition( true )
			.RunAll();
		foreach ( var armTrace in armTraces )
		{
			var hitBoneName = ResolveArmHitBoneName( armTrace );
			var hitSide = !string.IsNullOrWhiteSpace( hitBoneName )
				&& _hostSkeleton!.ByName.TryGetValue( hitBoneName, out var hitBone )
					? hitBone.ArmSide
					: 0;
			if ( !SkeletonOcclusionPolicy.IsOccludedByArm(
				target.IsWeaponBone,
				target.ArmSide,
				hitSide ) )
			{
				if ( hitSide == target.ArmSide && hitSide != 0 )
				{
					sameArmHits++;
					if ( string.IsNullOrWhiteSpace( sameArmExample ) )
						sameArmExample = hitBoneName;
				}
				else
				{
					unknownArmHits++;
					if ( string.IsNullOrWhiteSpace( unknownArmExample ) )
						unknownArmExample = hitBoneName;
				}
				continue;
			}

			var gap = targetDistance - armTrace.Distance;
			if ( !SkeletonOverlayStyle.IsOccludingDepth(
				targetDistance,
				armTrace.Distance ) )
			{
				nearArmHits++;
				continue;
			}

			oppositeArmHits++;
			if ( armTrace.Distance >= nearestDistance )
				continue;

			nearestDistance = armTrace.Distance;
			description = string.IsNullOrWhiteSpace( hitBoneName )
				? $"arms hitbox (unknown bone) at {armTrace.Distance:0.###}"
				: $"arms hitbox ({hitBoneName}) at {armTrace.Distance:0.###}";
		}

		description +=
			$"; armHits=opposite:{oppositeArmHits},self:{sameArmHits}"
			+ $"({sameArmExample}),near:{nearArmHits},unknown:{unknownArmHits}"
			+ $"({unknownArmExample}); target={targetDistance:0.###},"
			+ $"clearance={depthClearance:0.###},weaponIgnored=True";
		return nearestDistance < float.MaxValue;
	}

	private string ResolveArmHitBoneName( SceneTraceResult trace )
	{
		var hitboxBoneName = trace.Hitbox?.Bone?.Name;
		if ( !string.IsNullOrWhiteSpace( hitboxBoneName ) )
			return hitboxBoneName;
		if ( trace.Bone >= 0 && _armsRenderer.IsValid() )
			return _armsRenderer!.Model.GetBoneName( trace.Bone );
		return "";
	}

	private void ReportOcclusionDiagnostic(
		HostBone target,
		string hitDescription,
		bool occluded )
	{
		var diagnostic = $"{target.Name}|{target.ArmSide}|{occluded}";
		if ( diagnostic.Equals( _lastOcclusionDiagnostic, StringComparison.Ordinal ) )
			return;

		_lastOcclusionDiagnostic = diagnostic;
		Log.Info(
			$"[Weapon Animator] X-ray diagnostic: target={target.Name}, "
			+ $"targetSide={target.ArmSide}, firstHit={hitDescription}, "
			+ $"reduced={occluded}." );
	}

	/// <summary>
	/// Bone depth drives the overlay gradient. <c>HostSkeleton.Bones</c> is topologically ordered,
	/// so one forward pass resolves every depth. <c>BuildCached</c> hands out a shared read-only
	/// instance, so the cache is keyed on that instance rather than storing depth on the bones.
	/// </summary>
	private void EnsureBoneDepths()
	{
		if ( ReferenceEquals( _boneDepthSource, _hostSkeleton ) )
			return;

		_boneDepthSource = _hostSkeleton;
		_boneDepths.Clear();
		_maxBoneDepth = 0;
		if ( _hostSkeleton is null )
			return;

		foreach ( var bone in _hostSkeleton.Bones )
		{
			var depth = !string.IsNullOrWhiteSpace( bone.ParentName )
				&& _boneDepths.TryGetValue( bone.ParentName, out var parentDepth )
					? parentDepth + 1
					: 0;
			_boneDepths[bone.Name] = depth;
			if ( depth > _maxBoneDepth
				&& SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Arm )
				_maxBoneDepth = depth;
		}
	}

	private void DrawHostSkeletonPass(
		EvaluatedPose pose,
		float alpha,
		bool useRenderedArms,
		bool occluded = false,
		IReadOnlySet<string>? onlyBones = null,
		IReadOnlySet<string>? excludedBones = null,
		IReadOnlySet<string>? occlusionStates = null )
	{
		var showIk = _controller.Document.Workspace.ShowIkBones;
		foreach ( var bone in _hostSkeleton!.Bones )
		{
			if ( onlyBones is not null && !onlyBones.Contains( bone.Name ) )
				continue;
			if ( excludedBones is not null && excludedBones.Contains( bone.Name ) )
				continue;

			if ( !TryGetDisplayedBoneTransform(
				pose,
				bone,
				useRenderedArms,
				out var transform ) )
				continue;

			var boneStyle = SkeletonBoneStyle.Resolve(
				SkeletonBoneStyle.Classify( bone ),
				_boneDepths.GetValueOrDefault( bone.Name ),
				_maxBoneDepth,
				showIk );
			// Skipping also drops the hitbox below, so hidden bones stop stealing clicks.
			if ( !boneStyle.Visible )
				continue;

			var color = occluded
				? SkeletonOverlayStyle.Occlude( boneStyle.Color )
				: boneStyle.Color;
			var boneAlpha = alpha * boneStyle.AlphaScale;
			var dotScale = occluded ? SkeletonOverlayStyle.OccludedDotScale : 1.0f;
			if ( !string.IsNullOrWhiteSpace( bone.ParentName )
				&& _hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone )
				&& TryGetDisplayedBoneTransform(
					pose,
					parentBone,
					useRenderedArms,
					out var parent ) )
			{
				var parentOccluded = occlusionStates?.Contains( parentBone.Name )
					?? occluded;
				if ( parentOccluded == occluded )
				{
					Gizmo.Draw.Color = color.WithAlpha( 0.45f * boneAlpha );
					Gizmo.Draw.Line( parent.Position, transform.Position );
				}
			}

			using var scope = Gizmo.Scope( $"host_bone:{bone.Name}", transform );
			var selected = bone.Name == _controller.Document.Workspace.SelectedBone;
			var radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 180.0f, 0.08f, 0.45f )
				* boneStyle.RadiusScale;
			Gizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( boneAlpha );
			if ( boneStyle.Hollow )
				Gizmo.Draw.LineSphere( 0, (selected ? radius * 0.7f : radius * 0.45f) * dotScale, 3 );
			else
				Gizmo.Draw.SolidSphere( 0, (selected ? radius * 0.7f : radius * 0.3f) * dotScale, 5, 4 );
			Gizmo.Hitbox.Sphere( new Sphere( 0, radius ) );
			if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )
				_controller.SelectBone( bone.Name );
		}
	}

	private void DrawMixedOcclusionLines(
		EvaluatedPose pose,
		bool useRenderedArms,
		IReadOnlySet<string> occludedBones,
		SkeletonOverlayStyle overlay )
	{
		var showIk = _controller.Document.Workspace.ShowIkBones;
		using var scope = Gizmo.Scope( "host_skeleton_occlusion_gradients" );
		Gizmo.Draw.IgnoreDepth = true;
		foreach ( var bone in _hostSkeleton!.Bones )
		{
			if ( string.IsNullOrWhiteSpace( bone.ParentName )
				|| !_hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone ) )
				continue;

			var boneOccluded = occludedBones.Contains( bone.Name );
			var parentOccluded = occludedBones.Contains( parentBone.Name );
			if ( boneOccluded == parentOccluded )
				continue;

			var boneStyle = SkeletonBoneStyle.Resolve(
				SkeletonBoneStyle.Classify( bone ),
				_boneDepths.GetValueOrDefault( bone.Name ),
				_maxBoneDepth,
				showIk );
			if ( !boneStyle.Visible )
				continue;

			var parentStyle = SkeletonBoneStyle.Resolve(
				SkeletonBoneStyle.Classify( parentBone ),
				_boneDepths.GetValueOrDefault( parentBone.Name ),
				_maxBoneDepth,
				showIk );
			if ( !parentStyle.Visible
				|| !TryGetDisplayedBoneTransform(
					pose,
					bone,
					useRenderedArms,
					out var boneTransform )
				|| !TryGetDisplayedBoneTransform(
					pose,
					parentBone,
					useRenderedArms,
					out var parentTransform ) )
				continue;

			var startVisual = overlay.ResolveLineVisual(
				parentStyle,
				parentOccluded );
			var endVisual = overlay.ResolveLineVisual(
				boneStyle,
				boneOccluded );
			var delta = boneTransform.Position - parentTransform.Position;
			for ( var segment = 0;
				segment < SkeletonOverlayStyle.OcclusionGradientSegments;
				segment++ )
			{
				var startFraction =
					segment / (float)SkeletonOverlayStyle.OcclusionGradientSegments;
				var endFraction =
					(segment + 1) / (float)SkeletonOverlayStyle.OcclusionGradientSegments;
				var visual = SkeletonLineVisual.Lerp(
					startVisual,
					endVisual,
					(startFraction + endFraction) * 0.5f );
				Gizmo.Draw.Color = visual.Color;
				Gizmo.Draw.LineThickness = visual.Thickness;
				Gizmo.Draw.Line(
					parentTransform.Position + (delta * startFraction),
					parentTransform.Position + (delta * endFraction) );
			}
		}
	}

	private bool TryGetDisplayedBoneTransform(
		EvaluatedPose pose,
		HostBone bone,
		bool useRenderedArms,
		out Transform transform )
	{
		if ( useRenderedArms && !bone.IsWeaponBone && _armsRenderer.IsValid() )
		{
			var rendererBone = _armsRenderer!.Model.Bones.GetBone( bone.Name );
			if ( rendererBone is not null
				&& _armsRenderer.TryGetBoneTransform( rendererBone, out transform ) )
				return true;
		}

		return pose.Model.TryGetValue( bone.Name, out transform );
	}

	private void DrawOnionSkins( WeaponAnimationClip clip )
	{
		if ( _hostSkeleton is null )
			return;
		var step = 1.0f / MathF.Max( clip.SampleRate, 1 );

		// Onion skins are context only. They share bone names with the live skeleton, so leaving
		// them interactive would put duplicate hit targets on neighbouring frames.
		using var scope = Gizmo.Scope( "onion_skins" );
		Gizmo.Hitbox.CanInteract = false;
		foreach ( var offset in new[] { -step, step } )
		{
			var time = Math.Clamp(
				_controller.Document.Workspace.TimelineTime + offset,
				0,
				clip.Duration );
			var onion = AnimationPoseEvaluator.Evaluate(
				_controller.Document,
				_hostSkeleton,
				clip,
				time );
			DrawHostSkeleton( onion, 0.18f );
		}
	}

	private void DrawGripTethers( EvaluatedPose pose )
	{
		if ( _controller.Document.Binding.PrimaryHand.IsBound )
		{
			DrawTether(
				_controller.Document.Binding.PrimaryHand,
				pose,
				pose.PrimaryHandGoal,
				_controller.Document.Binding.PrimaryHand.Reachable,
				"hand_R" );
		}
		if ( _controller.Document.Binding.Configuration == GripConfiguration.TwoHanded )
		{
			if ( _controller.Document.Binding.SupportHand.IsBound )
			{
				DrawTether(
					_controller.Document.Binding.SupportHand,
					pose,
					pose.SupportHandGoal,
					_controller.Document.Binding.SupportHand.Reachable,
					"hand_L" );
			}
		}
	}

	private static void DrawTether(
		RigTarget target,
		EvaluatedPose pose,
		Transform? solvedGoal,
		bool reachable,
		string handBone )
	{
		if ( !pose.Model.TryGetValue( handBone, out var hand ) )
			return;

		// Draw the goal the IK actually solved toward. The raw binding transform is the bind-time
		// value, so it drifts away from the hand as soon as a clip animates the control - which made
		// the tether read as "far out of reach" while the hand sat correctly on the weapon.
		Transform targetTransform;
		if ( solvedGoal is { } goal )
		{
			targetTransform = goal;
		}
		else
		{
			targetTransform = target.Transform;
			if ( !string.IsNullOrWhiteSpace( target.AttachedBone )
				&& pose.Model.TryGetValue( target.AttachedBone, out var attached ) )
			{
				targetTransform = new Transform(
					attached.PointToWorld( target.Transform.Position ),
					attached.Rotation * target.Transform.Rotation );
			}
		}

		// Scoped so the colour and thickness do not leak into whatever draws next.
		using var scope = Gizmo.Scope( "grip_tether" );
		Gizmo.Draw.Color = reachable ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Coral;
		Gizmo.Draw.LineThickness = 2.5f;
		Gizmo.Draw.Line( hand.Position, targetTransform.Position );
		Gizmo.Draw.SolidSphere( targetTransform.Position, 0.18f, 8, 6 );
	}

	private void DrawSelectedAnchorControl( WeaponAnchor anchor )
	{
		if ( !_sourceRenderer.IsValid() )
			return;

		var sourceTransform = _sourceRenderer!.WorldTransform;
		var liveWorld = new Transform(
			sourceTransform.PointToWorld( anchor.LocalPosition ),
			sourceTransform.Rotation * anchor.LocalRotation );
		var token = $"anchor:{anchor.Kind}";
		var dragging = IsCalibrationDrag( token );
		var startWorld = dragging ? _calibrationGizmoStartWorld : liveWorld;
		var startLocal = dragging
			? _calibrationGizmoStartLocal
			: new Transform( anchor.LocalPosition, anchor.LocalRotation );
		var basis = CalibrationGizmoBasis( startWorld );

		// Scale is deliberately dropped from the scope. Feeding the gizmo a scaled transform
		// resizes its handles by the calibration scale, and feeding it a rotated one makes the
		// handles point along the rig's local axes while the result is applied in world space.
		using var scope = Gizmo.Scope(
			$"anchor_control:{anchor.Kind}",
			new Transform( startWorld.Position, basis ) );
		Gizmo.Draw.Color = AnchorColor( anchor.Kind );
		Gizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.24f ) );

		if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
		{
			if ( Gizmo.Control.Rotate( "anchor_rotate", Rotation.Identity, out var delta ) )
			{
				BeginCalibrationGizmoDrag(
					token,
					$"Rotate {anchor.Name} anchor",
					liveWorld,
					new Transform( anchor.LocalPosition, anchor.LocalRotation ) );
				// Rotate reports the total rotation since the grab, so it applies to the start.
				var snapped = SnapRotation( delta );
				var rotation = _controller.Document.Workspace.LocalGizmos
					? (startLocal.Rotation * snapped).Normal
					: (sourceTransform.Rotation.Inverse
						* snapped
						* sourceTransform.Rotation
						* startLocal.Rotation).Normal;
				_controller.UpdateContinuousEdit( document =>
				{
					var selected = document.Calibration.FindAnchor( anchor.Id );
					if ( selected is null )
						return;
					selected.LocalRotation = rotation;
					document.Calibration.Confirmed = false;
				} );
			}
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Move
			&& Gizmo.Control.Position( "anchor_move", Vector3.Zero, out var moveDelta, basis ) )
		{
			BeginCalibrationGizmoDrag(
				token,
				$"Move {anchor.Name} anchor",
				liveWorld,
				new Transform( anchor.LocalPosition, anchor.LocalRotation ) );
			_calibrationGizmoMoveDelta += moveDelta;
			var world = SnapPositionDelta(
				_calibrationGizmoStartWorld.Position,
				_calibrationGizmoMoveDelta,
				basis );
			var local = sourceTransform.PointToLocal( world );
			_controller.UpdateContinuousEdit( document =>
			{
				var selected = document.Calibration.FindAnchor( anchor.Id );
				if ( selected is null )
					return;
				selected.LocalPosition = local;
				document.Calibration.Confirmed = false;
			} );
		}
	}

	private void DrawWholeRigControl()
	{
		var document = _controller.Document;
		var framing = document.Workspace.FirstPersonPreview;
		var live = framing
			? document.Calibration.FramingTransform
			: document.Calibration.PhysicalTransform;
		var token = framing ? "rig:framing" : "rig:physical";
		var dragging = IsCalibrationDrag( token );
		var start = dragging ? _calibrationGizmoStartWorld : live;
		var basis = CalibrationGizmoBasis( start );

		using var scope = Gizmo.Scope(
			"whole_rig",
			new Transform( start.Position, basis ) );
		Gizmo.Draw.Color = WeaponAnimatorTheme.Amber;
		Gizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.3f ) );

		if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
		{
			if ( Gizmo.Control.Rotate( "rig_rotate", Rotation.Identity, out var delta ) )
			{
				BeginCalibrationGizmoDrag( token, "Refine whole-rig rotation", live, live );
				var snapped = SnapRotation( delta );
				var rotation = document.Workspace.LocalGizmos
					? (_calibrationGizmoStartWorld.Rotation * snapped).Normal
					: (snapped * _calibrationGizmoStartWorld.Rotation).Normal;
				_controller.UpdateContinuousEdit( d =>
					SetRigTransform( d, framing, target => target.WithRotation( rotation ) ) );
			}
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Move
			&& Gizmo.Control.Position( "rig_move", Vector3.Zero, out var moveDelta, basis ) )
		{
			BeginCalibrationGizmoDrag( token, "Refine whole-rig position", live, live );
			_calibrationGizmoMoveDelta += moveDelta;
			var position = SnapPositionDelta(
				_calibrationGizmoStartWorld.Position,
				_calibrationGizmoMoveDelta,
				basis );
			_controller.UpdateContinuousEdit( d =>
				SetRigTransform( d, framing, target => target.WithPosition( position ) ) );
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Scale
			&& Gizmo.Control.Scale( "rig_scale", Vector3.Zero, out var scaleDelta, basis ) )
		{
			BeginCalibrationGizmoDrag( token, "Refine whole-rig scale", live, live );
			_calibrationGizmoScaleDelta += scaleDelta / 0.01f;
			// Rig scale is uniform, so respond to whichever handle is being dragged rather than
			// only the X axis. The uniform centre handle reports all three equally.
			var dominant = DominantAxis( _calibrationGizmoScaleDelta );
			var factor = MathF.Max(
				1.0f + dominant * ScaleGizmoSensitivity,
				0.0001f );
			var uniform = MathF.Max(
				_calibrationGizmoStartWorld.Scale.x * factor,
				0.0001f );
			_controller.UpdateContinuousEdit( d =>
			{
				SetRigTransform( d, framing, target => target.WithScale( uniform ) );
				if ( !framing )
					d.Calibration.UniformScale = uniform;
			} );
		}
	}

	internal static float DominantAxis( Vector3 value )
	{
		var dominant = value.x;
		if ( MathF.Abs( value.y ) > MathF.Abs( dominant ) )
			dominant = value.y;
		if ( MathF.Abs( value.z ) > MathF.Abs( dominant ) )
			dominant = value.z;
		return dominant;
	}

	// The whole rig and its anchors are authored in world space unless Local is toggled on.
	private Rotation CalibrationGizmoBasis( Transform start ) =>
		_controller.Document.Workspace.LocalGizmos
			? start.Rotation
			: Rotation.Identity;

	private static void SetRigTransform(
		WeaponAnimationDocument document,
		bool framing,
		Func<Transform, Transform> edit )
	{
		if ( framing )
		{
			document.Calibration.FramingTransform =
				edit( document.Calibration.FramingTransform );
			return;
		}

		document.Calibration.PhysicalTransform =
			edit( document.Calibration.PhysicalTransform );
		document.Calibration.Confirmed = false;
	}

	private bool IsCalibrationDrag( string target ) =>
		_calibrationGizmoTarget.Equals( target, StringComparison.Ordinal );

	// Calibration gizmos accumulate into one undo entry, matching the animation gizmos below.
	private void BeginCalibrationGizmoDrag(
		string target,
		string description,
		Transform startWorld,
		Transform startLocal )
	{
		// Reopen if an unrelated mutation closed our continuous edit mid-drag, otherwise
		// UpdateContinuousEdit silently discards the rest of the drag.
		if ( IsCalibrationDrag( target ) && _controller.IsContinuousEditActive )
			return;

		EndCalibrationGizmoDrag();
		_calibrationGizmoTarget = target;
		_calibrationGizmoStartWorld = startWorld;
		_calibrationGizmoStartLocal = startLocal;
		_calibrationGizmoMoveDelta = Vector3.Zero;
		_calibrationGizmoScaleDelta = Vector3.Zero;
		_controller.BeginContinuousEdit( description );
	}

	private void FinishCalibrationGizmoDragIfReleased()
	{
		if ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )
			return;

		// Requires both signals. Gizmo.Pressed can read false between frames while the mouse is
		// still held, and ending on that alone splits one drag into an undo entry per frame.
		if ( Gizmo.Pressed.Any
			|| global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Left ) )
			return;

		EndCalibrationGizmoDrag();
	}

	private void EndCalibrationGizmoDrag()
	{
		if ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )
			return;

		_calibrationGizmoTarget = "";
		_calibrationGizmoMoveDelta = Vector3.Zero;
		_calibrationGizmoScaleDelta = Vector3.Zero;
		_controller.EndContinuousEdit();
	}

	private void DrawAnimationControl()
	{
		var context = SelectionTransformContext.Resolve( _controller );
		if ( context is null )
			return;

		var dragging = _animationGizmoTarget.Equals(
			context.Target,
			StringComparison.OrdinalIgnoreCase );
		var startWorld = dragging ? _animationGizmoStartWorld : context.WorldTransform;
		var basis = context.LocalSpace
			? startWorld.Rotation
			: Rotation.Identity;
		var gizmoTransform = new Transform( startWorld.Position, basis );
		using var scope = Gizmo.Scope( $"animate:{context.Target}", gizmoTransform );
		Gizmo.Draw.Color = context.Kind == RigControlKind.Weapon
			? WeaponAnimatorTheme.Amber
			: WeaponAnimatorTheme.Cyan;
		Gizmo.Draw.LineSphere( new Sphere( 0, 0.25f ) );

		if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
		{
			if ( Gizmo.Control.Rotate( "rotate", Rotation.Identity, out var delta ) )
			{
				BeginAnimationGizmoDrag( context );
				var snapped = SnapRotation( delta );
				var local = _animationGizmoStartLocal;
				if ( context.LocalSpace )
				{
					local.Rotation = (_animationGizmoStartLocal.Rotation * snapped).Normal;
				}
				else
				{
					var editedWorld = _animationGizmoStartWorld.WithRotation(
						(snapped * _animationGizmoStartWorld.Rotation).Normal );
					local = WorldToLocal( editedWorld, _animationGizmoStartParent );
				}
				_controller.UpdateTransformEditContinuous(
					context.Target,
					context.Kind,
					local );
			}
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Move
			&& Gizmo.Control.Position( "move", Vector3.Zero, out var delta, basis ) )
		{
			BeginAnimationGizmoDrag( context );
			_animationGizmoMoveDelta += delta;
			var position = SnapPositionDelta(
				_animationGizmoStartWorld.Position,
				_animationGizmoMoveDelta,
				basis );
			var editedWorld = _animationGizmoStartWorld.WithPosition( position );
			var local = WorldToLocal( editedWorld, _animationGizmoStartParent );
			_controller.UpdateTransformEditContinuous(
				context.Target,
				context.Kind,
				local );
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Scale
			&& Gizmo.Control.Scale( "scale", Vector3.Zero, out var scaleDelta, basis ) )
		{
			BeginAnimationGizmoDrag( context );
			_animationGizmoScaleDelta += scaleDelta / 0.01f;
			var local = ScaleFromStart(
				_animationGizmoStartLocal,
				_animationGizmoStartWorld,
				_animationGizmoStartParent,
				context.LocalSpace,
				_animationGizmoScaleDelta );
			_controller.UpdateTransformEditContinuous(
				context.Target,
				context.Kind,
				local );
		}
	}

	private void BeginAnimationGizmoDrag( SelectionTransformContext context )
	{
		if ( _animationGizmoTarget.Equals(
			context.Target,
			StringComparison.OrdinalIgnoreCase )
			&& _animationGizmoKind == context.Kind )
			return;

		EndAnimationGizmoDrag();
		_animationGizmoTarget = context.Target;
		_animationGizmoKind = context.Kind;
		_animationGizmoStartLocal = context.LocalTransform;
		_animationGizmoStartWorld = context.WorldTransform;
		_animationGizmoStartParent = context.ParentTransform;
		_animationGizmoMoveDelta = Vector3.Zero;
		_animationGizmoScaleDelta = Vector3.Zero;
		_controller.BeginContinuousEdit(
			$"{TransformModeName( TransformMode )} {context.Target}" );
	}

	private void FinishAnimationGizmoDragIfReleased()
	{
		if ( string.IsNullOrWhiteSpace( _animationGizmoTarget )
			|| Gizmo.Pressed.Any )
			return;

		EndAnimationGizmoDrag();
	}

	private void EndAnimationGizmoDrag()
	{
		if ( string.IsNullOrWhiteSpace( _animationGizmoTarget ) )
			return;

		_animationGizmoTarget = "";
		_animationGizmoMoveDelta = Vector3.Zero;
		_animationGizmoScaleDelta = Vector3.Zero;
		_animationGizmoStartParent = null;
		_controller.EndContinuousEdit();
	}

	private Rotation SnapRotation( Rotation delta ) =>
		_controller.Document.Workspace.SnapRotation ? Gizmo.Snap( delta ) : delta;

	private Vector3 SnapPositionDelta( Vector3 start, Vector3 movement, Rotation localSpace )
	{
		if ( !_controller.Document.Workspace.SnapPosition )
			return start + movement;

		return Gizmo.Snap( start, movement, localSpace );
	}

	internal static Transform WorldToLocal( Transform world, Transform? parent ) =>
		parent is null ? world : parent.Value.ToLocal( world );

	internal static Transform ScaleFromStart(
		Transform startLocal,
		Transform startWorld,
		Transform? parent,
		bool localSpace,
		Vector3 accumulatedDelta )
	{
		var factor = ClampScale(
			Vector3.One + accumulatedDelta * ScaleGizmoSensitivity );
		if ( localSpace )
			return startLocal.WithScale(
				ClampScale( startLocal.Scale * factor ) );

		var editedWorld = startWorld.WithScale(
			ClampScale( startWorld.Scale * factor ) );
		return WorldToLocal( editedWorld, parent );
	}

	private static Vector3 ClampScale( Vector3 scale ) =>
		new(
			MathF.Max( scale.x, 0.0001f ),
			MathF.Max( scale.y, 0.0001f ),
			MathF.Max( scale.z, 0.0001f ) );

	private void DrawMeasurement()
	{
		var measurement = _controller.Document.Calibration.Measurement;
		if ( !measurement.HasFirstPoint )
			return;

		var transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;
		var a = transform.PointToWorld( measurement.FirstPoint );
		Gizmo.Draw.Color = WeaponAnimatorTheme.Cyan;
		Gizmo.Draw.SolidSphere( a, 0.15f, 8, 6 );
		if ( !measurement.HasSecondPoint )
			return;

		var b = transform.PointToWorld( measurement.SecondPoint );
		Gizmo.Draw.SolidSphere( b, 0.15f, 8, 6 );
		Gizmo.Draw.LineThickness = 2;
		Gizmo.Draw.Line( a, b );
		Gizmo.Draw.ScreenText(
			$"{measurement.FirstPoint.Distance( measurement.SecondPoint ):0.###} source units",
			(a + b) * 0.5f,
			new Vector2( 8, -8 ) );
	}

	private void DrawAnchors()
	{
		var transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;
		foreach ( var anchor in _controller.Document.Calibration.Anchors )
		{
			var world = transform.PointToWorld( anchor.LocalPosition );
			var color = AnchorColor( anchor.Kind );
			var markerScale = Math.Clamp( world.Distance( _camera.WorldPosition ) / 75.0f, 0.45f, 1.4f );
			var labelOffset = AnchorLabelOffset( anchor.Kind );
			var leaderEnd = world
				+ _camera.WorldRotation.Right * labelOffset.x * markerScale
				+ _camera.WorldRotation.Up * labelOffset.y * markerScale;
			Gizmo.Draw.Color = color.WithAlpha( 0.75f );
			Gizmo.Draw.LineThickness = 1.5f;
			Gizmo.Draw.Line( world, leaderEnd );
			Gizmo.Draw.ScreenText(
				$"[{AnchorCode( anchor.Kind )}] {CalibrationSelection.DisplayName( anchor ).ToUpperInvariant()}",
				leaderEnd,
				new Vector2( 6, -6 ),
				size: 11 );
			var token = CalibrationSelection.Anchor( anchor );
			using var scope = Gizmo.Scope(
				$"anchor:{anchor.Id:N}",
				new Transform( world, transform.Rotation * anchor.LocalRotation ) );
			var selected = _controller.Document.Workspace.SelectedControl == token;
			Gizmo.Draw.Color = selected ? Color.White : color;
			Gizmo.Draw.SolidSphere( Vector3.Zero, selected ? 0.24f : 0.18f, 8, 6 );
			Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, 0.32f ) );
			if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )
				_controller.SelectControl( token );
		}

		var rear = _controller.Document.Calibration.GetAnchor( AnchorKind.RearBore );
		var front = _controller.Document.Calibration.GetAnchor( AnchorKind.FrontBore );
		if ( rear is null || front is null )
			return;
		Gizmo.Draw.Color = WeaponAnimatorTheme.Amber;
		Gizmo.Draw.LineThickness = 2;
		Gizmo.Draw.Arrow(
			transform.PointToWorld( rear.LocalPosition ),
			transform.PointToWorld( front.LocalPosition ),
			0.6f,
			0.25f );
	}

	private void DrawScreenGuides()
	{
		var document = _controller.Document;
		if ( !document.Workspace.ShowGuides )
			return;

		var viewport = new Rect( 0, 0, Size.x, Size.y );
		var guideAspect = GuideAspect( document.Calibration.AspectGuide );
		var viewportAspect = Size.x / MathF.Max( Size.y, 1 );
		Rect guide;
		if ( viewportAspect > guideAspect )
		{
			var width = Size.y * guideAspect;
			guide = new Rect( (Size.x - width) * 0.5f, 0, width, Size.y );
		}
		else
		{
			var height = Size.x / guideAspect;
			guide = new Rect( 0, (Size.y - height) * 0.5f, Size.x, height );
		}

		Gizmo.Draw.ScreenRect(
			viewport,
			Color.Transparent,
			borderColor: Color.White.WithAlpha( 0.05f ),
			borderSize: new Vector4( 1 ) );
		Gizmo.Draw.ScreenRect(
			guide,
			Color.Transparent,
			borderColor: Color.White.WithAlpha( 0.35f ),
			borderSize: new Vector4( 1 ) );

		if ( document.Calibration.ShowSafeArea )
		{
			Gizmo.Draw.ScreenRect(
				guide.Shrink( guide.Width * 0.05f, guide.Height * 0.05f ),
				Color.Transparent,
				borderColor: WeaponAnimatorTheme.Cyan.WithAlpha( 0.24f ),
				borderSize: new Vector4( 1 ) );
		}

		if ( document.Calibration.ShowCrosshair )
		{
			Gizmo.Draw.Color = Color.White.WithAlpha( 0.65f );
			Gizmo.Draw.ScreenText( "+", guide.Center, size: 19, flags: TextFlag.Center );
		}

		Gizmo.Draw.Color = WeaponAnimatorTheme.Muted;
		var mode = document.Workspace.FirstPersonPreview
			? "VIEWMODEL CAMERA"
			: document.Workspace.FreeLookCamera
				? "FREE LOOK"
				: "ORBIT";
		Gizmo.Draw.ScreenText(
			$"{mode} · {document.Calibration.AspectGuide} · {document.Calibration.HorizontalFov:0}° HFOV",
			new Vector2( 12, 52 ),
			size: 10 );
	}

	private void DrawViewportToolReadout()
	{
		Gizmo.Draw.ScreenRect(
			new Rect( 109, 15, 1, 18 ),
			Color.White.WithAlpha( 0.14f ) );
		Gizmo.Draw.ScreenRect(
			new Rect( 157, 15, 1, 18 ),
			Color.White.WithAlpha( 0.14f ) );
		Gizmo.Draw.ScreenRect(
			new Rect( MathF.Max( Width - 47, 66 ), 15, 1, 18 ),
			Color.White.WithAlpha( 0.14f ) );
		Gizmo.Draw.ScreenRect(
			TransformReadoutRect,
			WeaponAnimatorTheme.Background.WithAlpha( 0.25f ) );
		var text = new TextRendering.Scope
		{
			Text = _transformModeText,
			TextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.78f ),
			FontSize = 10 * global::Editor.Application.DpiScale,
			FontName = "Inter",
			FontWeight = 500,
			LineHeight = 1
		};
		Gizmo.Draw.ScreenText(
			text,
			new Vector2(
				TransformReadoutRect.Left + 6,
				TransformReadoutRect.Center.y ),
			TextFlag.LeftCenter );
	}

	private void DrawCameraSpeedOverlay()
	{
		if ( _sinceCameraSpeedChanged >= 1.8f )
			return;

		var elapsed = (float)_sinceCameraSpeedChanged;
		var alpha = elapsed <= 0.9f
			? 1.0f
			: 1.0f - Math.Clamp( (elapsed - 0.9f) / 0.9f, 0, 1 );
		var rect = new Rect(
			MathF.Max( (Width - 150) * 0.5f, 0 ),
			Width >= 720 ? 10 : Width >= 620 ? 46 : 82,
			150,
			28 );
		Gizmo.Draw.ScreenRect(
			rect,
			WeaponAnimatorTheme.Background.WithAlpha( 0.55f * alpha ) );
		var text = new TextRendering.Scope
		{
			Text = $"CAMERA SPEED  {_controller.Document.Workspace.CameraMoveSpeed:0.##}×",
			TextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.9f * alpha ),
			FontSize = 10 * global::Editor.Application.DpiScale,
			FontName = "Inter",
			FontWeight = 500,
			LineHeight = 1
		};
		Gizmo.Draw.ScreenText( text, rect.Center, TextFlag.Center );
	}

	private bool TryPickSourceSurface( Vector2 localPosition, out Vector3 modelPosition )
	{
		modelPosition = default;
		if ( !_sourceRenderer.IsValid() || _sourceRenderer!.Model is null )
			return false;

		var ray = GetRay( localPosition );
		var localRay = ray.ToLocal( _sourceRenderer.WorldTransform );
		var trace = _sourceRenderer.Model.Trace.Ray( localRay, 8192 ).Run();
		if ( !trace.Hit )
			return false;

		modelPosition = trace.HitPosition;
		return true;
	}

	private void ApplyPickedPoint( Vector3 localPosition )
	{
		var mode = PickMode;
		var anchorId = PickAnchorId;
		PickMode = ViewportPickMode.None;
		PickAnchorId = default;
		var token = "";
		_controller.Mutate( $"Set {PickLabel( mode )}", document =>
		{
			var measurement = document.Calibration.Measurement;
			switch ( mode )
			{
				case ViewportPickMode.MeasurementFirst:
					measurement.FirstPoint = localPosition;
					measurement.HasFirstPoint = true;
					break;
				case ViewportPickMode.MeasurementSecond:
					measurement.SecondPoint = localPosition;
					measurement.HasSecondPoint = true;
					break;
				case ViewportPickMode.CustomAnchor:
					// Placing an existing custom anchor must not disturb its stored attachment name.
					if ( document.Calibration.FindAnchor( anchorId ) is not { } custom )
						break;
					custom.BoneName = document.Workspace.SelectedBone;
					custom.LocalPosition = localPosition;
					document.Calibration.Confirmed = false;
					token = CalibrationSelection.Anchor( custom );
					break;
				default:
					var kind = PickAnchorKind( mode );
					document.Calibration.SetAnchor( new WeaponAnchor
					{
						Kind = kind,
						Name = CalibrationSelection.DisplayName( kind ),
						BoneName = document.Workspace.SelectedBone,
						LocalPosition = localPosition
					} );
					document.Calibration.Confirmed = false;
					token = CalibrationSelection.Anchor( kind );
					break;
			}
		} );
		if ( !string.IsNullOrEmpty( token ) )
			_controller.SelectControl( token );
		StatusChanged?.Invoke( $"{PickLabel( mode )} set at {localPosition}." );
	}

	private static AnchorKind PickAnchorKind( ViewportPickMode mode ) => mode switch
	{
		ViewportPickMode.GripAnchor => AnchorKind.Grip,
		ViewportPickMode.RearBoreAnchor => AnchorKind.RearBore,
		ViewportPickMode.FrontBoreAnchor => AnchorKind.FrontBore,
		ViewportPickMode.MuzzleAnchor => AnchorKind.Muzzle,
		ViewportPickMode.EjectAnchor => AnchorKind.Eject,
		_ => AnchorKind.Custom
	};

	private static string PickLabel( ViewportPickMode mode ) => mode switch
	{
		ViewportPickMode.MeasurementFirst => "measurement point A",
		ViewportPickMode.MeasurementSecond => "measurement point B",
		ViewportPickMode.GripAnchor => "primary grip",
		ViewportPickMode.RearBoreAnchor => "alignment marker — rear",
		ViewportPickMode.FrontBoreAnchor => "alignment marker — front",
		ViewportPickMode.MuzzleAnchor => "muzzle",
		ViewportPickMode.EjectAnchor => "eject",
		ViewportPickMode.CustomAnchor => "custom anchor",
		_ => "point"
	};

	private static Color AnchorColor( AnchorKind kind ) => kind switch
	{
		AnchorKind.Grip => WeaponAnimatorTheme.Cyan,
		AnchorKind.RearBore => new Color( 0.64f, 0.48f, 0.95f ),
		AnchorKind.FrontBore => WeaponAnimatorTheme.Amber,
		AnchorKind.Muzzle => WeaponAnimatorTheme.Coral,
		AnchorKind.Eject => WeaponAnimatorTheme.Green,
		_ => Color.White
	};

	private static Vector2 AnchorLabelOffset( AnchorKind kind ) => kind switch
	{
		AnchorKind.Grip => new Vector2( -1.8f, 1.1f ),
		AnchorKind.RearBore => new Vector2( 1.5f, 1.7f ),
		AnchorKind.FrontBore => new Vector2( 1.7f, 0.8f ),
		AnchorKind.Muzzle => new Vector2( 2.1f, -0.5f ),
		AnchorKind.Eject => new Vector2( -1.7f, 1.8f ),
		_ => new Vector2( 1.5f, 1.0f )
	};

	private static string AnchorCode( AnchorKind kind ) => kind switch
	{
		AnchorKind.Grip => "G",
		AnchorKind.RearBore => "AR",
		AnchorKind.FrontBore => "AF",
		AnchorKind.Muzzle => "M",
		AnchorKind.Eject => "E",
		_ => "A"
	};

	private static float GuideAspect( string guide ) => guide switch
	{
		"4:3" => 4.0f / 3.0f,
		"21:9" => 21.0f / 9.0f,
		_ => 16.0f / 9.0f
	};
}
#nullable enable
using System;

namespace AssetDoctor;

/// <summary>Focuses an editor-known asset in its Asset Browser view.</summary>
public static class AssetBrowserNavigator
{
    /// <summary>Attempts to focus an asset by logical path and falls back to highlighting the path.</summary>
    public static void FocusAsset(string path)
    {
        if(string.IsNullOrWhiteSpace(path)) return;
        var asset = AssetSystem.All.FirstOrDefault(item =>
            item != null && string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase));
        if(asset == null)
        {
            EditorEvent.Run("assetsystem.highlight", path);
            Log.Warning($"Asset Doctor could not select '{path}'; sent an Asset Browser highlight instead.");
            return;
        }

        var browser = AssetBrowser.Get();
        var assetBrowser = browser?.GetBrowser(asset);
        if(assetBrowser == null)
        {
            Log.Warning($"Asset Doctor could not locate an Asset Browser view for '{path}'.");
            return;
        }

        assetBrowser.FocusOnAsset(asset, true);
    }
}