Search the source of every open source package.
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( "&", "&" )
.Replace( "<", "<" )
.Replace( ">", ">" )
.Replace( "\"", """ );
}
}
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&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);
}
}