Search the source of every open source package.
823 results
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;
namespace HammerTextureBrowser;
[Dock( "Editor", "OG Texture Browser", "texture" )]
public sealed class HammerTextureBrowserDock : Widget
{
private static readonly Regex QuotedMaterialProperty = new( "\"(?<key>[^\"]+)\"\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant );
private static readonly string[] PrimaryTextureKeys =
[
"TextureColor",
"TextureBaseColor",
"TextureAlbedo",
"AlbedoTexture",
"BaseTexture",
"BaseColorTexture",
"g_tColor",
"g_tBaseColor",
"g_tAlbedo"
];
private readonly HammerTextureList TextureList;
private readonly ComboBox SizeCombo;
private readonly LineEdit FilterEdit;
private readonly ComboBox KeywordsCombo;
private readonly Checkbox OnlyUsedTextures;
private readonly Label SelectedTextureLabel;
private readonly Label TextureSizeLabel;
private readonly Label CountLabel;
private readonly Button MarkButton;
private readonly Button ReplaceButton;
private readonly Button ReloadButton;
private readonly Button OpenSourceButton;
private List<Asset> AllMaterials = new();
private Asset SelectedMaterial;
private string KeywordFilter = string.Empty;
private int PreviewSize = 128;
public HammerTextureBrowserDock( Widget parent ) : base( parent )
{
MinimumSize = new( 420, 320 );
WindowTitle = "OG Texture Browser";
SetWindowIcon( "texture" );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
TextureList = Layout.Add( new HammerTextureList( this ), 1 );
TextureList.OnTextureSelected = SelectMaterial;
TextureList.OnTextureActivated = SelectMaterial;
TextureList.OnOpenInEditor = OpenMaterialSource;
TextureList.OnOpenInAssetBrowser = OpenInAssetBrowser;
var bottom = Layout.Add( new Widget( this ) );
bottom.Layout = Layout.Column();
bottom.Layout.Margin = 4;
bottom.Layout.Spacing = 3;
bottom.FixedHeight = Theme.RowHeight * 2 + 12;
bottom.OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( bottom.LocalRect );
return false;
};
var topRow = bottom.Layout.AddRow();
topRow.Spacing = 5;
var sizeBlock = topRow.Add( new Widget( this ) );
sizeBlock.FixedWidth = 138;
sizeBlock.MinimumWidth = 138;
sizeBlock.MaximumWidth = 138;
sizeBlock.Layout = Layout.Row();
sizeBlock.Layout.Margin = 0;
sizeBlock.Layout.Spacing = 5;
AddSmallLabel( sizeBlock.Layout, "Size:" );
SizeCombo = sizeBlock.Layout.Add( new ComboBox( sizeBlock ) );
SizeCombo.FixedWidth = 88;
SizeCombo.MinimumWidth = 88;
SizeCombo.MaximumWidth = 88;
SizeCombo.AddItem( "32x32", null, () => SetPreviewSize( 32 ) );
SizeCombo.AddItem( "64x64", null, () => SetPreviewSize( 64 ) );
SizeCombo.AddItem( "128x128", null, () => SetPreviewSize( 128 ) );
SizeCombo.AddItem( "256x256", null, () => SetPreviewSize( 256 ) );
SizeCombo.AddItem( "1:1", null, () => SetPreviewSize( 128 ) );
SizeCombo.CurrentIndex = 2;
StyleBottomInput( SizeCombo, 24 );
AddSmallLabel( topRow, "Filter:", 58 );
FilterEdit = topRow.Add( new LineEdit( this ) );
FilterEdit.FixedWidth = 176;
FilterEdit.MinimumWidth = 176;
FilterEdit.MaximumWidth = 176;
FilterEdit.PlaceholderText = "texture name";
FilterEdit.TextChanged += _ => RefreshList();
StyleBottomInput( FilterEdit );
SelectedTextureLabel = topRow.Add( new Label( this ) );
SelectedTextureLabel.MinimumWidth = 180;
SelectedTextureLabel.Alignment = TextFlag.LeftCenter;
SelectedTextureLabel.Text = "";
OpenSourceButton = topRow.Add( new Button( "Open Source" ) );
OpenSourceButton.FixedWidth = 96;
OpenSourceButton.Clicked = OpenSelectedSource;
var bottomRow = bottom.Layout.AddRow();
bottomRow.Spacing = 5;
OnlyUsedTextures = bottomRow.Add( new Checkbox( "Only used textures" ) );
OnlyUsedTextures.FixedWidth = 138;
OnlyUsedTextures.StateChanged += _ => RefreshList();
AddSmallLabel( bottomRow, "Keywords:", 58 );
KeywordsCombo = bottomRow.Add( new ComboBox( this ) );
KeywordsCombo.FixedWidth = 176;
KeywordsCombo.MinimumWidth = 176;
KeywordsCombo.MaximumWidth = 176;
KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
StyleBottomInput( KeywordsCombo, 24 );
MarkButton = bottomRow.Add( new Button( "Mark" ) );
MarkButton.FixedWidth = 76;
MarkButton.Clicked = MarkSelectedMaterial;
ReplaceButton = bottomRow.Add( new Button( "Replace" ) );
ReplaceButton.FixedWidth = 76;
ReplaceButton.Clicked = ReplaceSelectedMaterial;
ReloadButton = bottomRow.Add( new Button( "Reload" ) );
ReloadButton.FixedWidth = 76;
ReloadButton.Clicked = Reload;
TextureSizeLabel = bottomRow.Add( new Label( this ) );
TextureSizeLabel.MinimumWidth = 0;
TextureSizeLabel.MaximumWidth = 68;
TextureSizeLabel.Alignment = TextFlag.RightCenter;
bottomRow.AddStretchCell( 1 );
CountLabel = bottomRow.Add( new Label( this ) );
CountLabel.MinimumWidth = 0;
CountLabel.MaximumWidth = 96;
CountLabel.Alignment = TextFlag.RightCenter;
ReloadMaterials();
SetPreviewSize( PreviewSize );
RefreshList();
UpdateSelectedMaterialUi();
}
private void Reload()
{
ReloadMaterials();
RefreshList();
UpdateSelectedMaterialUi();
}
private static void AddSmallLabel( Layout row, string text, int fixedWidth = 0 )
{
var label = row.Add( new Label( text ) );
label.Alignment = fixedWidth > 0 ? TextFlag.RightCenter : TextFlag.LeftCenter;
label.FixedWidth = fixedWidth > 0 ? fixedWidth : text.Length * 6 + 4;
}
private static void StyleBottomInput( Widget widget, int fixedHeight = 22 )
{
widget.FixedHeight = fixedHeight;
widget.SetStyles(
"background-color: #2d3136; " +
"border: 1px solid #555c64; " +
"border-radius: 2px; " +
"color: #f2f2f2; " +
"padding-top: 0px; " +
"padding-bottom: 0px; " +
"padding-left: 5px; " +
"padding-right: 5px;" );
}
private void ReloadMaterials()
{
var menuPath = EditorUtility.Projects.GetAll()
.FirstOrDefault( x => x.Config.Ident == "menu" )
?.GetAssetsPath()
.NormalizeFilename( false );
AllMaterials = AssetSystem.All
.Where( IsBrowsableMaterial )
.Where( x => !IsMenuAsset( x, menuPath ) )
.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
.ToList();
RebuildKeywords();
}
private static bool IsBrowsableMaterial( Asset asset )
{
if ( asset is null )
return false;
if ( asset.AssetType != AssetType.Material )
return false;
if ( asset.AbsolutePath?.Contains( ".sbox/cloud/", StringComparison.OrdinalIgnoreCase ) ?? false )
return false;
return true;
}
private static bool IsMenuAsset( Asset asset, string menuPath )
{
if ( string.IsNullOrEmpty( menuPath ) )
return false;
return asset.AbsolutePath?.NormalizeFilename( false ).StartsWith( menuPath, StringComparison.OrdinalIgnoreCase ) ?? false;
}
private void RebuildKeywords()
{
var tags = AllMaterials
.SelectMany( x => x.Tags )
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
.ToList();
KeywordsCombo.Clear();
KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
foreach ( var tag in tags )
{
var tagValue = tag;
KeywordsCombo.AddItem( tagValue, null, () => SetKeywordFilter( tagValue ) );
}
KeywordsCombo.CurrentIndex = 0;
}
private void SetKeywordFilter( string tag )
{
KeywordFilter = tag ?? string.Empty;
RefreshList();
}
private void SetPreviewSize( int size )
{
PreviewSize = size;
TextureList.SetPreviewSize( PreviewSize );
RefreshList();
}
private void RefreshList()
{
if ( TextureList is null )
return;
IEnumerable<Asset> materials = AllMaterials;
var query = FilterEdit?.Text ?? string.Empty;
if ( !string.IsNullOrWhiteSpace( query ) )
{
var parts = query.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
materials = materials.Where( x => MatchesQuery( x, parts ) );
}
if ( !string.IsNullOrWhiteSpace( KeywordFilter ) )
{
materials = materials.Where( x => x.Tags.Any( tag => tag.Equals( KeywordFilter, StringComparison.OrdinalIgnoreCase ) ) );
}
if ( OnlyUsedTextures?.Value ?? false )
{
var used = HammerMaterialSelection.GetUsedMaterialKeys();
materials = materials.Where( x => used.Contains( x.RelativePath ) || used.Contains( x.AbsolutePath ) || used.Contains( TextureDisplayName( x ) ) );
}
var entries = materials
.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
.Select( x => new HammerTextureEntry( x ) )
.ToList();
TextureList.SetItems( entries );
if ( CountLabel is not null )
{
CountLabel.Text = $"{entries.Count:n0} textures";
CountLabel.Update();
}
if ( SelectedMaterial is not null && entries.All( x => x.Asset != SelectedMaterial ) )
TextureList.UnselectAll();
}
private static bool MatchesQuery( Asset asset, IEnumerable<string> parts )
{
var name = TextureDisplayName( asset );
var type = asset.AssetType?.FriendlyName ?? string.Empty;
IEnumerable<string> tags = asset.Tags;
foreach ( var part in parts )
{
var search = part;
var negated = false;
if ( search.StartsWith( "-" ) )
{
search = search[1..];
negated = true;
}
var matched =
name.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
type.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
tags.Any( x => x.Contains( search, StringComparison.OrdinalIgnoreCase ) );
if ( !negated && !matched )
return false;
if ( negated && matched )
return false;
}
return true;
}
private void SelectMaterial( Asset material )
{
if ( material?.AssetType != AssetType.Material )
return;
SelectedMaterial = material;
EditorUtility.InspectorObject = material;
HammerMaterialSelection.SetCurrentMaterial( material );
UpdateSelectedMaterialUi();
}
private void UpdateSelectedMaterialUi()
{
var hasMaterial = SelectedMaterial is not null;
SelectedTextureLabel.Text = hasMaterial ? TextureDisplayName( SelectedMaterial ) : "";
SelectedTextureLabel.ToolTip = SelectedMaterial?.RelativePath ?? string.Empty;
TextureSizeLabel.Text = GetTextureSizeText( SelectedMaterial );
TextureSizeLabel.ToolTip = SelectedMaterial?.AbsolutePath ?? string.Empty;
MarkButton.Enabled = hasMaterial;
ReplaceButton.Enabled = hasMaterial;
OpenSourceButton.Enabled = hasMaterial;
SelectedTextureLabel.Update();
TextureSizeLabel.Update();
MarkButton.Update();
ReplaceButton.Update();
OpenSourceButton.Update();
}
private static string GetTextureSizeText( Asset asset )
{
if ( asset is null )
return "";
var texturePath = GetPrimaryMaterialTexturePath( asset );
if ( string.IsNullOrWhiteSpace( texturePath ) )
return "";
try
{
var texture = Texture.Load( texturePath );
if ( texture is null || !texture.IsValid() || texture.Width <= 0 || texture.Height <= 0 )
return "";
return $"{texture.Width}x{texture.Height}";
}
catch
{
return "";
}
}
private static string GetPrimaryMaterialTexturePath( Asset asset )
{
if ( string.IsNullOrWhiteSpace( asset?.AbsolutePath ) || !File.Exists( asset.AbsolutePath ) )
return null;
try
{
var properties = QuotedMaterialProperty.Matches( File.ReadAllText( asset.AbsolutePath ) )
.Select( x => (Key: x.Groups["key"].Value, Value: NormalizeTexturePath( x.Groups["value"].Value )) )
.Where( x => IsTexturePath( x.Value ) )
.ToList();
foreach ( var key in PrimaryTextureKeys )
{
var match = properties.FirstOrDefault( x => x.Key.Equals( key, StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( match.Value ) )
return match.Value;
}
var colorMatch = properties.FirstOrDefault( x =>
(x.Key.Contains( "color", StringComparison.OrdinalIgnoreCase ) ||
x.Key.Contains( "albedo", StringComparison.OrdinalIgnoreCase ) ||
x.Key.Contains( "base", StringComparison.OrdinalIgnoreCase )) &&
!x.Key.Contains( "tint", StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( colorMatch.Value ) )
return colorMatch.Value;
return properties.FirstOrDefault().Value;
}
catch
{
return null;
}
}
private static string NormalizeTexturePath( string path )
{
return path?.Trim().Replace( '\\', '/' );
}
private static bool IsTexturePath( string path )
{
var extension = Path.GetExtension( path );
return extension.Equals( ".vtex", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tga", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".png", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".jpg", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".jpeg", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tif", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tiff", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".psd", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".exr", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".hdr", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".bmp", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".webp", StringComparison.OrdinalIgnoreCase );
}
private void MarkSelectedMaterial()
{
if ( SelectedMaterial is null )
return;
HammerMaterialSelection.SelectFacesUsingMaterial( SelectedMaterial );
}
private void ReplaceSelectedMaterial()
{
if ( SelectedMaterial is null )
return;
HammerMaterialSelection.AssignAssetToSelection( SelectedMaterial );
}
private void OpenSelectedSource()
{
OpenMaterialSource( SelectedMaterial );
}
private void OpenMaterialSource( Asset asset )
{
if ( asset is null )
return;
if ( asset.CanOpenInEditor )
{
asset.OpenInEditor();
return;
}
EditorUtility.OpenFileFolder( asset.AbsolutePath );
}
private void OpenInAssetBrowser( Asset asset )
{
if ( asset is null )
return;
LocalAssetBrowser.OpenTo( asset, true );
}
internal static string TextureDisplayName( Asset asset )
{
var path = asset?.RelativePath ?? asset?.Name ?? "";
path = path.NormalizeFilename( false, false );
if ( path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
path = path["materials/".Length..];
var extension = Path.GetExtension( path );
if ( !string.IsNullOrEmpty( extension ) )
path = path[..^extension.Length];
return path;
}
}
internal sealed class HammerTextureList : ListView
{
private int PreviewSize = 128;
public Action<Asset> OnTextureSelected;
public Action<Asset> OnTextureActivated;
public Action<Asset> OnOpenInEditor;
public Action<Asset> OnOpenInAssetBrowser;
public HammerTextureList( Widget parent ) : base( parent )
{
MultiSelect = false;
FocusMode = FocusMode.TabOrClick;
Margin = 2;
ItemSpacing = 2;
ItemPaint = PaintTextureItem;
ItemSelected = item => SelectTexture( item, false );
ItemActivated = item => SelectTexture( item, true );
ItemDrag = StartTextureDrag;
ItemContextMenu = OpenTextureContextMenu;
ItemScrollEnter = item => (item as HammerTextureEntry)?.OnScrollEnter();
ItemScrollExit = item => (item as HammerTextureEntry)?.OnScrollExit();
OnPaintOverride = PaintBackground;
}
public void SetPreviewSize( int previewSize )
{
PreviewSize = previewSize;
ItemSize = new Vector2( PreviewSize + 4, PreviewSize + 18 );
Update();
}
public void SetItems( IEnumerable<HammerTextureEntry> entries )
{
base.SetItems( entries.Cast<object>() );
Update();
}
private bool PaintBackground()
{
Paint.ClearPen();
Paint.SetBrush( Color.Black );
Paint.DrawRect( LocalRect );
return false;
}
private void SelectTexture( object item, bool activated )
{
if ( item is not HammerTextureEntry entry )
return;
if ( activated )
OnTextureActivated?.Invoke( entry.Asset );
else
OnTextureSelected?.Invoke( entry.Asset );
}
private bool StartTextureDrag( object item )
{
if ( item is not HammerTextureEntry entry || entry.Asset is null )
return false;
SelectItem( entry );
SelectTexture( entry, false );
var drag = new Drag( this );
drag.Data.Object = entry.Asset;
drag.Data.Text = entry.Asset.RelativePath;
drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );
foreach ( var selected in SelectedItems.OfType<HammerTextureEntry>() )
{
if ( selected == entry || selected.Asset is null )
continue;
drag.Data.Text += "\n" + selected.Asset.RelativePath;
}
drag.Execute();
return true;
}
private void OpenTextureContextMenu( object item )
{
if ( item is not HammerTextureEntry entry )
return;
SelectItem( entry );
SelectTexture( entry, false );
var menu = new ContextMenu( this );
menu.AddOption( "Open in Editor", "edit", () => OnOpenInEditor?.Invoke( entry.Asset ) )
.Enabled = entry.Asset is not null;
menu.AddOption( "Open in Asset Browser", "search", () => OnOpenInAssetBrowser?.Invoke( entry.Asset ) )
.Enabled = entry.Asset is not null;
menu.OpenAtCursor( false );
}
private void PaintTextureItem( VirtualWidget item )
{
if ( item.Object is not HammerTextureEntry entry )
return;
var rect = item.Rect.Shrink( 1 );
var active = Paint.HasPressed;
var selected = Paint.HasSelected || Paint.HasPressed;
var hover = !selected && Paint.HasMouseOver;
Paint.ClearPen();
Paint.SetBrush( Color.Black );
Paint.DrawRect( rect );
if ( selected )
{
Paint.SetPen( Theme.Primary, 2 );
Paint.ClearBrush();
Paint.DrawRect( rect.Grow( -1 ) );
}
else if ( hover )
{
Paint.SetPen( Theme.ControlBackground.Lighten( 0.35f ), 1 );
Paint.ClearBrush();
Paint.DrawRect( rect.Grow( -1 ) );
}
var previewRect = rect;
previewRect.Width = PreviewSize;
previewRect.Height = PreviewSize;
previewRect.Left += MathF.Max( 0, (rect.Width - PreviewSize) * 0.5f );
entry.DrawPreview( previewRect, active );
var nameRect = previewRect;
nameRect.Top = previewRect.Bottom;
nameRect.Height = 12;
Paint.ClearPen();
Paint.SetBrush( selected ? Theme.Primary.Lighten( active ? -0.1f : 0.05f ) : new Color( 0.0f, 0.05f, 0.85f ) );
Paint.DrawRect( nameRect );
Paint.SetDefaultFont( 6 );
Paint.SetPen( Color.White );
var label = Paint.GetElidedText( entry.DisplayName, nameRect.Width - 4, ElideMode.Middle );
Paint.DrawText( nameRect.Shrink( 2, 0 ), label, TextFlag.LeftCenter | TextFlag.SingleLine );
}
}
internal sealed class HammerTextureEntry
{
public Asset Asset { get; }
public string DisplayName { get; }
public HammerTextureEntry( Asset asset )
{
Asset = asset;
DisplayName = HammerTextureBrowserDock.TextureDisplayName( asset );
}
public void OnScrollEnter()
{
if ( Asset is null || Asset.HasCachedThumbnail )
return;
EditorEvent.Register( this );
Asset.GetAssetThumb( true );
}
public void OnScrollExit()
{
if ( Asset is null )
return;
Asset.CancelThumbBuild();
EditorEvent.Unregister( this );
}
public void DrawPreview( Rect rect, bool active )
{
Paint.ClearPen();
Paint.SetBrush( active ? Color.Black.Lighten( 0.08f ) : Color.Black );
Paint.DrawRect( rect );
var thumb = Asset?.GetAssetThumb( true ) ?? AssetType.Material?.Icon128;
if ( thumb is null )
return;
var drawRect = rect;
var scale = MathF.Min( rect.Width / thumb.Width, rect.Height / thumb.Height );
if ( scale > 0 && float.IsFinite( scale ) )
{
drawRect.Width = MathF.Min( rect.Width, thumb.Width * scale );
drawRect.Height = MathF.Min( rect.Height, thumb.Height * scale );
drawRect.Left = rect.Left + (rect.Width - drawRect.Width) * 0.5f;
drawRect.Top = rect.Top + (rect.Height - drawRect.Height) * 0.5f;
}
Paint.BilinearFiltering = true;
Paint.Draw( drawRect, thumb );
Paint.BilinearFiltering = false;
}
}
internal static class HammerMaterialSelection
{
private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private static Type HammerType;
private static bool HasResolvedHammerType;
public static void SetCurrentMaterial( Asset asset ) => InvokeHammerAssetMethod( "SetCurrentMaterial", asset );
public static void SelectFacesUsingMaterial( Asset asset ) => InvokeHammerAssetMethod( "SelectFacesUsingMaterial", asset );
public static void AssignAssetToSelection( Asset asset ) => InvokeHammerAssetMethod( "AssignAssetToSelection", asset );
public static HashSet<string> GetUsedMaterialKeys()
{
var keys = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
try
{
var activeMap = GetHammerType()?.GetProperty( "ActiveMap", StaticFlags )?.GetValue( null );
if ( activeMap is null )
return keys;
var world = activeMap.GetType().GetProperty( "World", InstanceFlags )?.GetValue( activeMap );
if ( world is null )
return keys;
CollectUsedMaterialKeys( world, keys );
}
catch
{
// Hammer throws when no map is open; in that case "Only used textures" should simply show none.
return keys;
}
return keys;
}
private static void CollectUsedMaterialKeys( object node, HashSet<string> keys )
{
if ( node is null )
return;
var materialMethod = node.GetType().GetMethod( "GetFaceMaterialAssets", InstanceFlags );
if ( materialMethod is not null && materialMethod.Invoke( node, null ) is IEnumerable materials )
{
foreach ( var item in materials )
{
if ( item is not Asset asset )
continue;
keys.Add( asset.RelativePath );
keys.Add( asset.AbsolutePath );
keys.Add( HammerTextureBrowserDock.TextureDisplayName( asset ) );
}
}
var children = node.GetType().GetProperty( "Children", InstanceFlags )?.GetValue( node ) as IEnumerable;
if ( children is null )
return;
foreach ( var child in children )
{
CollectUsedMaterialKeys( child, keys );
}
}
private static void InvokeHammerAssetMethod( string methodName, Asset asset )
{
if ( asset is null )
return;
var method = GetHammerType()?.GetMethod( methodName, StaticFlags, binder: null, types: new[] { typeof( Asset ) }, modifiers: null );
if ( method is null )
return;
try
{
method.Invoke( null, new object[] { asset } );
}
catch
{
// This dock still works as a browser if Hammer is not the active editor surface.
}
}
private static Type GetHammerType()
{
if ( HasResolvedHammerType )
return HammerType;
HasResolvedHammerType = true;
foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
{
HammerType = assembly.GetType( "Editor.MapEditor.Hammer" );
if ( HammerType is not null )
break;
}
return HammerType;
}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
global using Dreams.UltimateLightManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Sandbox;
namespace Sandbox.AssetBrowserAddon;
/// <summary>
/// Dialog that captures the settings for a new custom Asset Browser location.
/// </summary>
public sealed class LocationEditor : Dialog
{
private readonly Action<CustomLocationDefinition> _onConfirm;
private readonly CustomLocationDefinition _initialDefinition;
private readonly LineEdit _nameInput;
private readonly LineEdit _iconInput;
private readonly LineEdit _includeInput;
private readonly LineEdit _excludeInput;
private readonly AssetTypeSelector _assetTypeSelector;
private readonly ToggleSwitch _projectOnlyToggle;
public LocationEditor(Action<CustomLocationDefinition> onConfirm, CustomLocationDefinition initialDefinition = null)
{
_onConfirm = onConfirm;
_initialDefinition = initialDefinition;
Window.Title = initialDefinition is null ? "Add Bookmark" : "Edit Bookmark";
Window.Size = new Vector2(500, 750);
Layout = Layout.Column();
Layout.Margin = 16f;
Layout.Spacing = 12f;
_nameInput = AddTextRow("Title", "My Bookmark");
_iconInput = AddIconRow("Icon", "bookmark");
Layout.Add( new Label( this ) { Text = "Asset Types" } );
_assetTypeSelector = Layout.Add( new AssetTypeSelector( this, StyleInput ) );
_projectOnlyToggle = Layout.Add( new ToggleSwitch( "Only include assets from this project", this ) );
_projectOnlyToggle.MinimumHeight = Theme.RowHeight;
_projectOnlyToggle.Value = true;
_includeInput = AddTextArea("Include Folders", "Separate folders with ;");
_excludeInput = AddTextArea("Exclude Folders", "Separate folders with ;");
if ( _initialDefinition is not null )
{
_nameInput.Text = _initialDefinition.Name;
_iconInput.Text = _initialDefinition.Icon;
_assetTypeSelector.SetSelected( _initialDefinition.AssetTypes );
_includeInput.Text = string.Join( ';', _initialDefinition.IncludeFolders ?? new List<string>() );
_excludeInput.Text = string.Join( ';', _initialDefinition.ExcludeFolders ?? new List<string>() );
_projectOnlyToggle.Value = _initialDefinition.ProjectAssetsOnly;
}
var buttonRow = Layout.AddRow();
buttonRow.Spacing = 8f;
buttonRow.AddStretchCell();
buttonRow.Add( new Button( "Cancel", this ) { Clicked = Close } );
var buttonLabel = _initialDefinition is null ? "Add Bookmark" : "Save Bookmark";
var buttonIcon = _initialDefinition is null ? "add" : "save";
buttonRow.Add( new Button.Primary( buttonLabel, buttonIcon, this ) { Clicked = Submit } );
}
private LineEdit AddTextRow(string label, string placeholder)
{
var row = Layout.AddRow();
row.Spacing = 8f;
row.Add( new Label( this ) { Text = label, FixedWidth = 130 } );
var input = row.Add( new LineEdit( this ) );
input.PlaceholderText = placeholder;
StyleInput( input );
return input;
}
private LineEdit AddIconRow(string label, string placeholder)
{
var row = Layout.AddRow();
row.Spacing = 8f;
row.Add( new Label( this ) { Text = label, FixedWidth = 130 } );
var input = row.Add( new LineEdit( this ) );
input.PlaceholderText = placeholder;
StyleInput( input );
var button = row.Add( new IconButton( "search", () => ShowIconPicker( input ), this ) );
button.ToolTip = "Browse material icons";
button.MinimumWidth = Theme.RowHeight;
return input;
}
private LineEdit AddTextArea(string label, string placeholder)
{
var column = Layout.Add( Layout.Column() );
column.Spacing = 4f;
column.Add( new Label( this ) { Text = label } );
var input = column.Add( new LineEdit( this ) );
input.PlaceholderText = placeholder;
StyleInput( input );
return input;
}
private void Submit()
{
var name = _nameInput.Text?.Trim();
if ( string.IsNullOrWhiteSpace( name ) )
{
EditorUtility.DisplayDialog( "Missing Title", "Please enter a title for the bookmark." );
return;
}
var icon = string.IsNullOrWhiteSpace( _iconInput.Text ) ? "extension" : _iconInput.Text.Trim();
var definition = _initialDefinition is null
? new CustomLocationDefinition()
: new CustomLocationDefinition { Id = _initialDefinition.Id };
definition.Name = name;
definition.Icon = icon;
definition.AssetTypes = _assetTypeSelector.SelectedTags.Select( NormalizeExtension ).Where( x => !string.IsNullOrWhiteSpace( x ) ).ToList();
definition.IncludeFolders = SplitToList( _includeInput.Text );
definition.ExcludeFolders = SplitToList( _excludeInput.Text );
definition.ProjectAssetsOnly = _projectOnlyToggle.Value;
_onConfirm?.Invoke( definition );
Close();
}
private void ShowIconPicker( LineEdit target )
{
var pickerType = AppDomain.CurrentDomain.GetAssemblies()
.Select( asm => asm.GetType( "Editor.IconPickerWidget", false ) )
.FirstOrDefault( t => t is not null );
var openPopup = pickerType?.GetMethod( "OpenPopup", BindingFlags.Public | BindingFlags.Static );
if ( openPopup is null )
{
EditorUtility.DisplayDialog( "Icon Picker", "Unable to locate the icon picker widget." );
return;
}
openPopup.Invoke( null, new object[]
{
this,
target.Text ?? string.Empty,
(Action<string>)(value => target.Text = value)
} );
}
private static List<string> SplitToList(string raw)
{
if ( string.IsNullOrWhiteSpace( raw ) )
return new List<string>();
return raw
.Split( new[] { ';', ',', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries )
.Select( part => part.Trim() )
.Where( part => part.Length > 0 )
.ToList();
}
private static string NormalizeExtension( string value )
{
if ( string.IsNullOrWhiteSpace( value ) )
return string.Empty;
return value.Trim().TrimStart( '.' ).ToLowerInvariant();
}
private static void StyleInput( LineEdit input )
{
var background = Theme.ControlBackground.Darken( 0.25f ).Hex;
var border = Theme.Border.Hex;
input.SetStyles( $"background-color: {background}; border-color: {border};" );
}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
using System;
using System.Linq;
using Sandbox;
namespace Editor.TerrainConvert;
/// <summary>
/// Which source channel to read the height value from.
/// </summary>
public enum HeightChannel
{
/// <summary> Red channel only. Standard for grayscale heightmaps. </summary>
Red,
Green,
Blue,
Alpha,
/// <summary> Rec.709 weighted luminance of RGB. </summary>
Luminance,
/// <summary> Average of the RGB channels. </summary>
Average,
/// <summary> The largest of the RGB channels. </summary>
Max,
}
/// <summary>
/// Bit depth of the output .raw file. s&box terrain stores heights as 16-bit,
/// so 16-bit is recommended for the best precision.
/// </summary>
public enum HeightBitDepth
{
/// <summary> 16-bit per height sample (recommended). 2 bytes per pixel. </summary>
Bit16,
/// <summary> 8-bit per height sample. 1 byte per pixel. </summary>
Bit8,
}
/// <summary>
/// How the source values are remapped into the 0..1 height range before quantizing.
/// </summary>
public enum HeightNormalize
{
/// <summary> Use values as-is, clamped to 0..1. </summary>
Clamp,
/// <summary> Stretch the min..max range of the image to fill 0..1 (good for HDR/EXR). </summary>
MinMaxStretch,
}
/// <summary>
/// Byte order of multi-byte (16-bit) samples in the output file.
/// </summary>
public enum HeightByteOrder
{
/// <summary> Little-endian. What the s&box terrain importer reads by default ("Windows"). </summary>
LittleEndian,
/// <summary> Big-endian ("Mac"). </summary>
BigEndian,
}
/// <summary>
/// Settings for converting an image into a terrain heightmap .raw file.
/// </summary>
public class HeightmapConvertSettings
{
/// <summary>
/// Which channel of the source image holds the height. Most heightmaps are
/// grayscale, where every channel is identical - <see cref="HeightChannel.Red"/> works for those.
/// </summary>
[Property]
public HeightChannel Channel { get; set; } = HeightChannel.Red;
/// <summary>
/// 16-bit gives the smoothest terrain and matches what s&box stores internally.
/// </summary>
[Property]
public HeightBitDepth BitDepth { get; set; } = HeightBitDepth.Bit16;
/// <summary>
/// Output heightmaps are always square. If 0, the smaller of the source
/// dimensions is used. The image is resampled to this resolution.
/// </summary>
[Property, Range( 0, 8192 )]
public int Resolution { get; set; } = 0;
/// <summary>
/// Round the output resolution down to the nearest power of two (e.g 1024, 2048).
/// The terrain system expects power-of-two heightmaps, so leave this on.
/// </summary>
[Property]
public bool PowerOfTwo { get; set; } = true;
/// <summary>
/// How source values are mapped to the height range. Use <see cref="HeightNormalize.MinMaxStretch"/>
/// for HDR images that don't already fill 0..1.
/// </summary>
[Property]
public HeightNormalize Normalize { get; set; } = HeightNormalize.Clamp;
/// <summary>
/// Flip the image vertically. Image and terrain coordinate origins often differ;
/// toggle this if your terrain comes out mirrored north/south.
/// </summary>
[Property]
public bool FlipVertical { get; set; } = false;
/// <summary>
/// Invert the heights (high becomes low). Useful for depth/inverted maps.
/// </summary>
[Property]
public bool Invert { get; set; } = false;
/// <summary>
/// Byte order of 16-bit samples. The s&box importer reads little-endian by default.
/// </summary>
[Property, ShowIf( nameof( BitDepth ), HeightBitDepth.Bit16 )]
public HeightByteOrder ByteOrder { get; set; } = HeightByteOrder.LittleEndian;
}
/// <summary>
/// Converts loaded images into raw single-channel heightmaps compatible with the
/// s&box terrain importer (square, 8 or 16 bit, raw samples with no header).
/// </summary>
public static class HeightmapConverter
{
/// <summary>
/// Convert a bitmap into a raw heightmap byte buffer.
/// </summary>
/// <param name="bitmap">Source image.</param>
/// <param name="settings">Conversion settings.</param>
/// <param name="resolution">The square resolution of the produced heightmap.</param>
/// <returns>Raw heightmap bytes ready to write to a .raw file.</returns>
public static byte[] Convert( Bitmap bitmap, HeightmapConvertSettings settings, out int resolution )
{
ArgumentNullException.ThrowIfNull( bitmap );
ArgumentNullException.ThrowIfNull( settings );
var pixels = bitmap.GetPixels();
int count = bitmap.Width * bitmap.Height;
// Extract the chosen channel as a float per pixel, then run the shared pipeline.
var values = new float[count];
for ( int i = 0; i < count; i++ )
values[i] = SampleChannel( pixels[i], settings.Channel );
return BuildRaw( values, bitmap.Width, bitmap.Height, settings, out resolution );
}
/// <summary>
/// Convert a decoded EXR into a raw heightmap byte buffer, reading height from the
/// chosen channel (falling back to Y/R/first channel if the exact one is absent).
/// </summary>
public static byte[] Convert( ExrImage exr, HeightmapConvertSettings settings, out int resolution )
{
ArgumentNullException.ThrowIfNull( exr );
ArgumentNullException.ThrowIfNull( settings );
var values = SampleChannel( exr, settings.Channel );
return BuildRaw( values, exr.Width, exr.Height, settings, out resolution );
}
/// <summary>
/// Shared pipeline: resample a single-channel float plane to a square power-of-two,
/// normalize into 0..1, optionally invert, and quantize to raw bytes.
/// </summary>
static byte[] BuildRaw( float[] values, int srcWidth, int srcHeight, HeightmapConvertSettings settings, out int resolution )
{
resolution = ResolveResolution( srcWidth, srcHeight, settings );
// Bilinear resample to a square of the target resolution, working entirely in float
// so we don't lose precision on high bit-depth / HDR sources. Resampling produces a
// fresh array; otherwise clone so the in-place normalize/invert never mutates a
// caller-owned buffer (e.g. an EXR's cached channel plane).
values = srcWidth != resolution || srcHeight != resolution
? ResampleBilinear( values, srcWidth, srcHeight, resolution, resolution )
: (float[])values.Clone();
ApplyNormalize( values, settings.Normalize );
if ( settings.Invert )
{
for ( int i = 0; i < values.Length; i++ )
values[i] = 1f - values[i];
}
return Quantize( values, resolution, settings );
}
static float[] ResampleBilinear( float[] src, int srcW, int srcH, int dstW, int dstH )
{
var dst = new float[dstW * dstH];
for ( int y = 0; y < dstH; y++ )
{
float fy = dstH > 1 ? (float)y / (dstH - 1) * (srcH - 1) : 0f;
int y0 = (int)fy;
int y1 = Math.Min( y0 + 1, srcH - 1 );
float ty = fy - y0;
for ( int x = 0; x < dstW; x++ )
{
float fx = dstW > 1 ? (float)x / (dstW - 1) * (srcW - 1) : 0f;
int x0 = (int)fx;
int x1 = Math.Min( x0 + 1, srcW - 1 );
float tx = fx - x0;
float top = MathX.Lerp( src[y0 * srcW + x0], src[y0 * srcW + x1], tx );
float bottom = MathX.Lerp( src[y1 * srcW + x0], src[y1 * srcW + x1], tx );
dst[y * dstW + x] = MathX.Lerp( top, bottom, ty );
}
}
return dst;
}
static float[] SampleChannel( ExrImage exr, HeightChannel channel )
{
// For multi-channel EXRs, blend RGB the same way the bitmap path does. For the
// common single-channel (Y) heightmap, every option resolves to that one plane.
switch ( channel )
{
case HeightChannel.Red: return exr.GetHeightChannel( "R" );
case HeightChannel.Green: return exr.GetHeightChannel( "G" );
case HeightChannel.Blue: return exr.GetHeightChannel( "B" );
case HeightChannel.Alpha: return exr.GetHeightChannel( "A" );
}
var r = exr.GetChannel( "R" );
var g = exr.GetChannel( "G" );
var b = exr.GetChannel( "B" );
// No RGB set - it's a luminance/single-channel image, use it directly.
if ( r is null || g is null || b is null )
return exr.GetHeightChannel();
var outv = new float[r.Length];
for ( int i = 0; i < outv.Length; i++ )
{
outv[i] = channel switch
{
HeightChannel.Luminance => 0.2126f * r[i] + 0.7152f * g[i] + 0.0722f * b[i],
HeightChannel.Average => (r[i] + g[i] + b[i]) / 3f,
HeightChannel.Max => Math.Max( r[i], Math.Max( g[i], b[i] ) ),
_ => r[i],
};
}
return outv;
}
static int ResolveResolution( int width, int height, HeightmapConvertSettings settings )
{
int res = settings.Resolution > 0 ? settings.Resolution : Math.Min( width, height );
if ( settings.PowerOfTwo )
res = RoundDownToPowerOfTwo( res );
return Math.Clamp( res, 4, 16384 );
}
static float SampleChannel( Color c, HeightChannel channel ) => channel switch
{
HeightChannel.Red => c.r,
HeightChannel.Green => c.g,
HeightChannel.Blue => c.b,
HeightChannel.Alpha => c.a,
HeightChannel.Luminance => 0.2126f * c.r + 0.7152f * c.g + 0.0722f * c.b,
HeightChannel.Average => (c.r + c.g + c.b) / 3f,
HeightChannel.Max => Math.Max( c.r, Math.Max( c.g, c.b ) ),
_ => c.r,
};
static void ApplyNormalize( float[] values, HeightNormalize mode )
{
if ( mode == HeightNormalize.MinMaxStretch )
{
float min = values.Min();
float max = values.Max();
float range = max - min;
if ( range > 1e-6f )
{
for ( int i = 0; i < values.Length; i++ )
values[i] = (values[i] - min) / range;
return;
}
// Flat image - fall through to clamp.
}
for ( int i = 0; i < values.Length; i++ )
values[i] = Math.Clamp( values[i], 0f, 1f );
}
static byte[] Quantize( float[] values, int resolution, HeightmapConvertSettings settings )
{
bool flip = settings.FlipVertical;
if ( settings.BitDepth == HeightBitDepth.Bit8 )
{
var bytes = new byte[resolution * resolution];
for ( int y = 0; y < resolution; y++ )
{
int srcY = flip ? resolution - 1 - y : y;
for ( int x = 0; x < resolution; x++ )
{
float v = Math.Clamp( values[srcY * resolution + x], 0f, 1f );
bytes[y * resolution + x] = (byte)MathF.Round( v * byte.MaxValue );
}
}
return bytes;
}
else
{
bool little = settings.ByteOrder == HeightByteOrder.LittleEndian;
var bytes = new byte[resolution * resolution * 2];
for ( int y = 0; y < resolution; y++ )
{
int srcY = flip ? resolution - 1 - y : y;
for ( int x = 0; x < resolution; x++ )
{
float v = Math.Clamp( values[srcY * resolution + x], 0f, 1f );
ushort h = (ushort)MathF.Round( v * ushort.MaxValue );
int o = (y * resolution + x) * 2;
if ( little )
{
bytes[o] = (byte)(h & 0xFF);
bytes[o + 1] = (byte)(h >> 8);
}
else
{
bytes[o] = (byte)(h >> 8);
bytes[o + 1] = (byte)(h & 0xFF);
}
}
}
return bytes;
}
}
/// <summary>
/// Rounds a value down to the nearest power of two.
/// </summary>
public static int RoundDownToPowerOfTwo( int value )
{
if ( value < 1 ) return 1;
value |= value >> 1;
value |= value >> 2;
value |= value >> 4;
value |= value >> 8;
value |= value >> 16;
return value - (value >> 1);
}
}
using Sandbox;
using Editor;
namespace RedSnail.RoadTool.Editor;
/// <summary>
/// Create and manage road and road intersection.
/// </summary>
[Title("Create Road/Intersection")]
[Icon("roundabout_left")]
[Alias("intersection")]
[Group("1")]
[Order(0)]
public class IntersectionTool : EditorTool
{
public override void OnEnabled()
{
}
public override Widget CreateToolSidebar()
{
ToolSidebarWidget sidebar = new ToolSidebarWidget();
sidebar.AddTitle("Intersection", "roundabout_left");
Layout group = sidebar.AddGroup("Create");
Layout row = Layout.Row();
IconButton road = sidebar.CreateButton("Create Road", "route", null, CreateRoad, true, row);
IconButton inter = sidebar.CreateButton("Create Intersection", "roundabout_left", null, CreateIntersection, true, row);
row.Spacing = 5;
row.AddStretchCell();
group.Add(row);
sidebar.Layout.Add(group);
sidebar.Layout.AddStretchCell();
return sidebar;
}
private static void CreateRoad()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road";
go.AddComponent<RoadComponent>();
}
private static void CreateIntersection()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road Intersection";
go.AddComponent<RoadIntersectionComponent>();
}
}
#if DEBUG
using Sandbox.Reactivity.Internals;
using Sandbox.UI;
namespace Sandbox.Reactivity.Editor.Debugger;
// ReSharper disable once ClassNeverInstantiated.Global
[Dock("Editor", "Reactivity Debugger", "local_fire_department")]
internal sealed partial class DebuggerWidget : Widget
{
private readonly TreeView _tree;
public DebuggerWidget(Widget? parent)
: base(parent)
{
Layout = new Column
{
Spacing = 2,
Children =
[
new Widget
{
Layout = new Row
{
Spacing = 2,
Children =
[
// TODO: search bar
new Widget
{
HorizontalSizeMode = SizeMode.Flexible,
},
new Widget
{
BackgroundColor = Theme.ControlBackground,
BorderRadius = Theme.ControlRadius,
Layout = new Row
{
Children =
[
new ToolButton("Settings", "more_vert", this)
{
MouseLeftPress = () => Settings<DebuggerWidget>.OpenContextMenu(this),
},
],
},
},
],
},
},
new Widget
{
BackgroundColor = Theme.ControlBackground,
BorderRadius = Theme.ControlRadius,
Layout = new Column
{
Children =
[
_tree = new TreeView
{
Margin = new Margin(8, 0),
SelectionOverride = () => EditorUtility.InspectorObject,
},
],
},
},
],
};
Effect.OnEffectRootCreated += OnEffectRootCreated;
}
public override void OnDestroyed()
{
foreach (var item in _tree.Items) // .Items makes a copy
{
if (item is EffectTreeNode node)
{
node.Dispose();
}
}
_tree.Clear();
Effect.OnEffectRootCreated -= OnEffectRootCreated;
}
private void OnEffectRootCreated(Effect root)
{
if (root.IsDisposed)
{
// just in case - effects from other scenes (e.g. prefab editors) might cause this to happen
return;
}
if (!ShowUiEffects && root.Parent is IReactivePanel)
{
return;
}
if (!ShowGameplayEffects && root.Parent is (Component or GameObject) and not IReactivePanel)
{
return;
}
var node = new EffectTreeNode(root);
_tree.AddItem(node);
if (AutoExpand)
{
_tree.Open(node, true);
}
}
}
#endif
#if DEBUG
using Sandbox.Reactivity.Internals;
namespace Sandbox.Reactivity.Editor.Inspector;
internal class SerializedReactiveObjectProperty(IReactiveObject reactive) : SerializedProperty
{
protected readonly IReactiveObject ReactiveObject = reactive;
public override string Name { get; } = reactive.Name ?? "Reactive Object";
public override string DisplayName => Name;
public override Type PropertyType => ReactiveObject.GetType();
public override bool IsEditable => false;
public override bool IsValid => ReactiveObject is not Effect { IsDisposed: true } && base.IsValid;
public override void SetValue<T>(T value)
{
}
public override T GetValue<T>(T defaultValue = default!)
{
return ValueToType(ReactiveObject, defaultValue);
}
}
#endif
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Document;
public sealed record CheckboxPayload : Payload
{
[JsonIgnore]
public override ControlType Kind => ControlType.Checkbox;
// Checkbox label text. Overrides Payload.Content (neutral default "").
public override string Content { get; init; } = "";
public override Length CheckboxSize { get; init; } = Length.Px( 16 );
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Inspector;
[CustomEditor( typeof( Edges ) )]
public sealed class EdgesControlWidget : ControlWidget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public override bool SupportsMultiEdit => true;
private readonly EdgesProxy _proxy;
private readonly SerializedObject _proxySerialized;
// Synchronous change events on both sides; without this guard SetValue would loop.
private bool _syncing;
private sealed class EdgesProxy
{
public Length Top { get; set; } = Length.Px( 0 );
public Length Right { get; set; } = Length.Px( 0 );
public Length Bottom { get; set; } = Length.Px( 0 );
public Length Left { get; set; } = Length.Px( 0 );
}
public EdgesControlWidget( SerializedProperty property ) : base( property )
{
Log.Info( $"{LogPrefix} EdgesControlWidget ctor for {property.Name}" );
Layout = Layout.Column();
Layout.Spacing = 2;
_proxy = new EdgesProxy();
_proxySerialized = EditorTypeLibrary.GetSerializedObject( _proxy );
var topRow = Layout.Add( Layout.Row() );
topRow.Spacing = 2;
AddSide( topRow, nameof( EdgesProxy.Top ), "border_top" );
AddSide( topRow, nameof( EdgesProxy.Right ), "border_right" );
var bottomRow = Layout.Add( Layout.Row() );
bottomRow.Spacing = 2;
AddSide( bottomRow, nameof( EdgesProxy.Bottom ), "border_bottom" );
AddSide( bottomRow, nameof( EdgesProxy.Left ), "border_left" );
_proxySerialized.OnPropertyChanged += OnProxyChanged;
SyncFromProperty();
}
private void AddSide( Layout row, string propName, string icon )
{
var prop = _proxySerialized.GetProperty( propName );
var lengthWidget = new LengthControlWidget( prop, icon );
row.Add( lengthWidget, 1 );
}
protected override void PaintControl()
{
// nothing
}
private void SyncFromProperty()
{
if ( _syncing ) return;
_syncing = true;
try
{
var e = SerializedProperty.GetValue<Edges>( Edges.Zero );
_proxySerialized.GetProperty( nameof( EdgesProxy.Top ) ).SetValue( e.Top );
_proxySerialized.GetProperty( nameof( EdgesProxy.Right ) ).SetValue( e.Right );
_proxySerialized.GetProperty( nameof( EdgesProxy.Bottom ) ).SetValue( e.Bottom );
_proxySerialized.GetProperty( nameof( EdgesProxy.Left ) ).SetValue( e.Left );
}
finally
{
_syncing = false;
}
}
private void OnProxyChanged( SerializedProperty property )
{
if ( _syncing ) return;
if ( ReadOnly || !SerializedProperty.IsEditable )
return;
_syncing = true;
try
{
var newValue = new Edges( _proxy.Top, _proxy.Right, _proxy.Bottom, _proxy.Left );
Log.Info( $"{LogPrefix} EdgesControlWidget OnProxyChanged {newValue}" );
PropertyStartEdit();
SerializedProperty.SetValue( newValue );
SignalValuesChanged();
PropertyFinishEdit();
}
finally
{
_syncing = false;
}
}
protected override void OnValueChanged()
{
base.OnValueChanged();
SyncFromProperty();
}
}
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Templates;
using Sandbox;
namespace Grains.RazorDesigner.Palette;
public class PalettePanel : Widget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const string CookiePrefix = "razordesigner.palette.";
// Click-to-add target. Window decides where the new record goes (typically active selection or root).
public event Action<ControlType> TypeAddRequested;
// Click-to-add a saved template. Window decides where to insert.
public event Action<PaletteTemplate> TemplateAddRequested;
private readonly PaletteTemplateStore _templateStore = new();
private CollapsibleSection _templatesSection;
private WrapPanel _templatesWrap;
public PaletteTemplateStore TemplateStore => _templateStore;
public PalettePanel( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
MinimumWidth = 180;
VerticalSizeMode = SizeMode.CanGrow;
var byCategory = new Dictionary<ControlCategory, List<ControlType>>();
foreach ( ControlType type in Enum.GetValues( typeof( ControlType ) ) )
{
var cat = ControlDefaults.For( type ).Category;
if ( !byCategory.TryGetValue( cat, out var list ) )
{
list = new List<ControlType>();
byCategory[cat] = list;
}
list.Add( type );
}
// Templates section (top of palette). Hidden when store is empty; rebuilt on Changed.
_templatesSection = new CollapsibleSection( this, "Templates", "bookmark" );
_templatesWrap = new WrapPanel( null )
{
MinItemWidth = 92,
ItemHeight = (int)( Theme.RowHeight + 4 ),
HSpacing = 4,
VSpacing = 4,
PaddingLeft = 4,
PaddingTop = 4,
PaddingRight = 14,
PaddingBottom = 4,
};
_templatesSection.BodyLayout.Add( _templatesWrap );
var templatesCookie = $"{CookiePrefix}templates.expanded";
_templatesSection.Expanded = EditorCookie.Get<bool>( templatesCookie, true );
_templatesSection.ExpandedChanged += expanded =>
{
EditorCookie.Set( templatesCookie, expanded );
Log.Info( $"{LogPrefix} Palette Templates {(expanded ? "expanded" : "collapsed")}" );
};
Layout.Add( _templatesSection );
_templateStore.Changed += RebuildTemplatesSection;
_templateStore.Scan(); // initial fill (also fires Changed and rebuilds the section)
foreach ( ControlCategory cat in Enum.GetValues( typeof( ControlCategory ) ) )
{
if ( !byCategory.TryGetValue( cat, out var list ) ) continue;
var section = new CollapsibleSection(
this,
ControlDefaults.CategoryDisplayName( cat ),
CategoryIcon( cat ) );
var wrap = new WrapPanel( null )
{
MinItemWidth = 92,
ItemHeight = (int)( Theme.RowHeight + 4 ),
HSpacing = 4,
VSpacing = 4,
PaddingLeft = 4,
PaddingTop = 4,
PaddingRight = 14, // clear the ScrollArea's vertical scrollbar
PaddingBottom = 4,
};
section.BodyLayout.Add( wrap );
foreach ( var t in list )
new PaletteTypeButton( wrap, this, t );
var cookieKey = $"{CookiePrefix}{cat}.expanded";
section.Expanded = EditorCookie.Get<bool>( cookieKey, DefaultExpanded( cat ) );
section.ExpandedChanged += expanded =>
{
EditorCookie.Set( cookieKey, expanded );
Log.Info( $"{LogPrefix} Palette category {cat} {(expanded ? "expanded" : "collapsed")}" );
};
Layout.Add( section );
}
Layout.AddStretchCell();
Log.Info( $"{LogPrefix} PalettePanel ctor (icon grid, {byCategory.Count} categories)" );
}
internal void NotifyTypeClicked( ControlType type )
{
Log.Info( $"{LogPrefix} PalettePanel.NotifyTypeClicked: {type}" );
TypeAddRequested?.Invoke( type );
}
internal void NotifyTemplateClicked( PaletteTemplate template )
{
Log.Info( $"{LogPrefix} PalettePanel.NotifyTemplateClicked: \"{template.Name}\"" );
TemplateAddRequested?.Invoke( template );
}
internal void RequestTemplateDelete( PaletteTemplate template )
{
var dialog = new Editor.Dialog( this );
dialog.Window.WindowTitle = "Delete template";
dialog.Window.SetWindowIcon( "delete" );
dialog.Window.SetModal( true, true );
dialog.Window.MinimumWidth = 320;
dialog.Layout = Layout.Column();
dialog.Layout.Margin = 16;
dialog.Layout.Spacing = 10;
dialog.Layout.Add( new Editor.Label( dialog )
{
Text = $"Delete template \"{template.Name}\"?",
} );
var hint = new Editor.Label( dialog )
{
Text = "Already-instantiated copies in open documents are unaffected.",
};
hint.SetStyles( "color: #888; font-size: 11px;" );
dialog.Layout.Add( hint );
var buttonRow = dialog.Layout.Add( Layout.Row() );
buttonRow.Spacing = 6;
buttonRow.AddStretchCell();
var cancel = new Editor.Button( dialog ) { Text = "Cancel", MinimumWidth = 72 };
cancel.MouseLeftPress += () => dialog.Close();
buttonRow.Add( cancel );
var del = new Editor.Button( dialog ) { Text = "Delete", MinimumWidth = 72 };
del.SetStyles( "color: #e07070;" );
del.MouseLeftPress += () =>
{
Log.Info( $"{LogPrefix} Palette delete confirmed: \"{template.Name}\"" );
_templateStore.Delete( template );
dialog.Close();
};
buttonRow.Add( del );
dialog.Window.AdjustSize();
dialog.Show();
}
private void RebuildTemplatesSection()
{
var templates = _templateStore.All;
// Hide the entire section (header + body) when there are no templates.
_templatesSection.Visible = templates.Count > 0;
using ( Editor.SuspendUpdates.For( _templatesWrap ) )
{
_templatesWrap.DestroyChildren();
foreach ( var t in templates )
new PaletteTemplateButton( _templatesWrap, this, t );
}
_templatesWrap.Relayout();
_templatesWrap.UpdateGeometry();
_templatesSection.UpdateGeometry();
UpdateGeometry();
Log.Info( $"{LogPrefix} PalettePanel.RebuildTemplatesSection: {templates.Count} tile(s), section.Visible={_templatesSection.Visible}" );
}
private static bool DefaultExpanded( ControlCategory cat ) =>
cat is ControlCategory.Layout or ControlCategory.Display or ControlCategory.Input;
private static string CategoryIcon( ControlCategory cat ) => cat switch
{
ControlCategory.Layout => "view_quilt",
ControlCategory.Display => "visibility",
ControlCategory.Input => "edit",
ControlCategory.Form => "list_alt",
_ => "category",
};
private sealed class PaletteTypeButton : Widget
{
private readonly PalettePanel _owner;
private readonly ControlType _type;
// InspectorIcon comes from the contract (engine-fidelity); drag defaults from ControlDefaults.
private readonly string _icon;
public PaletteTypeButton( Widget parent, PalettePanel owner, ControlType type ) : base( parent )
{
_owner = owner;
_type = type;
_icon = ContractScanner.Table.Get( type ).InspectorIcon;
ToolTip = type.ToString();
Cursor = CursorShape.Finger;
MouseTracking = true;
IsDraggable = true;
}
protected override void OnPaint()
{
var rect = LocalRect.Shrink( 1 );
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var tint = ControlPresentation.IconTint( _type );
var fillAlpha = Paint.HasMouseOver ? 0.35f : 0.15f;
var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
Paint.SetPen( tint.WithAlpha( borderAlpha ) );
Paint.DrawRect( rect, 3 );
var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
Paint.DrawIcon( iconRect, _icon, 16, TextFlag.Center );
var textRect = rect;
textRect.Left = iconRect.Right + 2;
textRect.Right -= 4;
Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
Paint.SetDefaultFont();
Paint.DrawText( textRect, _type.ToString(), TextFlag.LeftCenter );
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
_owner.NotifyTypeClicked( _type );
}
protected override void OnDragStart()
{
base.OnDragStart();
var drag = new Drag( this );
drag.Data.Object = _type;
drag.Data.Text = $"palette:{_type}";
drag.Execute();
Log.Info( $"{LogPrefix} PaletteTypeButton.OnDragStart: {_type}" );
}
}
private sealed class PaletteTemplateButton : Widget
{
private readonly PalettePanel _owner;
private readonly PaletteTemplate _template;
public PaletteTemplateButton( Widget parent, PalettePanel owner, PaletteTemplate template ) : base( parent )
{
_owner = owner;
_template = template;
ToolTip = template.Name;
Cursor = CursorShape.Finger;
MouseTracking = true;
IsDraggable = true;
}
protected override void OnPaint()
{
var rect = LocalRect.Shrink( 1 );
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var tint = ControlPresentation.TemplateTint;
var fillAlpha = Paint.HasMouseOver ? 0.18f : 0.08f;
var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
Paint.SetPen( tint.WithAlpha( borderAlpha ) );
Paint.DrawRect( rect, 3 );
var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
var icon = string.IsNullOrEmpty( _template.IconName ) ? "bookmark" : _template.IconName;
Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
Paint.DrawIcon( iconRect, icon, 16, TextFlag.Center );
var textRect = rect;
textRect.Left = iconRect.Right + 2;
textRect.Right -= 4;
Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
Paint.SetDefaultFont();
Paint.DrawText( textRect, _template.Name, TextFlag.LeftCenter );
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
_owner.NotifyTemplateClicked( _template );
}
protected override void OnContextMenu( ContextMenuEvent e )
{
base.OnContextMenu( e );
var menu = new Menu( this );
menu.AddOption( "Delete…", "delete", () => _owner.RequestTemplateDelete( _template ) );
menu.OpenAtCursor();
e.Accepted = true;
}
protected override void OnDragStart()
{
base.OnDragStart();
var drag = new Drag( this );
drag.Data.Object = _template;
drag.Data.Text = $"template:{_template.Name}";
drag.Execute();
Log.Info( $"{LogPrefix} PaletteTemplateButton.OnDragStart: \"{_template.Name}\"" );
}
}
}
namespace Grains.RazorDesigner.Projection.CSharp;
public abstract record CSharpOp;
// File-level scaffold
public sealed record HeaderBanner( string ClassName, string Namespace ) : CSharpOp;
public sealed record UsingDirective( string Namespace ) : CSharpOp;
public sealed record NamespaceOpen( string Namespace ) : CSharpOp;
public sealed record ClassOpen( string ClassName, string BaseClass ) : CSharpOp;
public sealed record ClassClose() : CSharpOp;
public sealed record FieldDecl(
string Visibility, string Type, string Name, string InitialExpr,
bool IsParameter, bool IsProperty = false ) : CSharpOp;
public sealed record MethodOpen(
string Visibility, bool IsOverride, bool IsAsync,
string ReturnType, string Name, string ParameterList ) : CSharpOp;
public sealed record MethodClose() : CSharpOp;
// Body-level
public sealed record Statement( string Code ) : CSharpOp; // single `;`-terminated line
public sealed record BlockOpen( string Header ) : CSharpOp; // e.g. `if ( <cond> )` — applier writes "<header> {\n" and indents
public sealed record BlockClose() : CSharpOp;
public sealed record BlankLine() : CSharpOp;
public sealed record Comment( string Text ) : CSharpOp; // `// <text>`
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.CSharp.Projectors;
namespace Grains.RazorDesigner.Projection.CSharp;
public static class CSharpProjector
{
public static CSharpResult Project(
IReadOnlyWiring wiring,
bool documentHasAnyBindings )
{
if ( wiring.Symbols.Count == 0 && !documentHasAnyBindings )
return new CSharpResult( System.Array.Empty<CSharpOp>(), null );
var ctx = new CSharpProjectorContext( wiring );
var ops = new List<CSharpOp>( 64 );
ops.Add( new HeaderBanner( wiring.ClassName, wiring.Namespace ) );
ops.Add( new UsingDirective( "Sandbox" ) );
ops.Add( new UsingDirective( "Sandbox.UI" ) );
foreach ( var u in wiring.Usings )
ops.Add( new UsingDirective( u ) );
ops.Add( new NamespaceOpen( wiring.Namespace ) );
ops.Add( new ClassOpen( wiring.ClassName, wiring.BaseClass ) );
// Step 3: body. SymbolProjector handles grouping + sorting + per-kind dispatch.
SymbolProjector.EmitAll( wiring, ops, ctx );
ops.Add( new ClassClose() );
var source = CSharpApplier.Apply( ops ).Replace( "\r\n", "\n" );
return new CSharpResult( ops, source );
}
}
using System.Collections.Generic;
using Grains.RazorDesigner.Wiring;
namespace Grains.RazorDesigner.Projection.CSharp.Projectors;
public static class ParameterSymbolProjector
{
public static void Emit( ParameterSymbol s, List<CSharpOp> ops, CSharpProjectorContext ctx )
{
var initial = s.Initial is null ? "default" : ExpressionEmitter.Emit( s.Initial, ctx );
ops.Add( new FieldDecl(
Visibility: "public", Type: s.Type, Name: s.Name,
InitialExpr: initial, IsParameter: true ) );
}
}
namespace Grains.RazorDesigner.Projection;
public static class Escape
{
public static string Html( string s )
{
if ( string.IsNullOrEmpty( s ) ) return "";
return s
.Replace( "&", "&" )
.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 );
}
}
}
namespace NodeEditorPlus.Blackboard;
public interface IBlackboardNodeGraph : INodeGraph
{
IEnumerable<IBlackboardParameter> Parameters { get; }
void AddParameter( IBlackboardParameter parameter );
void RemoveParameter( IBlackboardParameter parameter );
IBlackboardParameter FindParameter( Guid identifier );
string SerializeParameters( IEnumerable<IBlackboardParameter> parameters );
IEnumerable<IBlackboardParameter> DeserializeParameters( string serialized );
}
namespace ShaderGraphPlus;
public class ClassBlackboardParameterType : IBlackboardParameterType
{
public virtual string Identifier => Type.FullName;
public TypeDescription Type { get; }
public DisplayInfo DisplayInfo { get; protected set; }
protected virtual string DefaultBaseName => "Parameter";
public ClassBlackboardParameterType( TypeDescription type )
{
Type = type;
if ( Type is not null )
DisplayInfo = DisplayInfo.ForType( Type.TargetType );
else
DisplayInfo = new DisplayInfo();
}
private string CheckName( ShaderGraphPlus graph, string name )
{
if ( string.IsNullOrWhiteSpace( name ) )
{
var id = 0;
while ( graph.HasParameterWithName( $"{DefaultBaseName}{id}" ) )
{
id++;
}
return $"{DefaultBaseName}{id}";
}
else
{
return name;
}
}
public virtual IBlackboardParameter CreateParameter( INodeGraph graph, string name = "" )
{
var sg = graph as ShaderGraphPlus;
var parameter = Type.Create<BlackboardParameter>();
parameter.Name = CheckName( sg, name );
parameter.Graph = sg;
return parameter;
}
internal static ClassBlackboardParameterType HookupParameterType( TypeDescription type )
{
var parameterType = new ClassBlackboardParameterType( type );
// Use these specific ClassBlackboardParameterType's instead. Fallback to the default just in case.
if ( type.TargetType.IsAssignableTo( typeof( IBlackboardMaterialParameter ) ) ||
type.TargetType.IsAssignableTo( typeof( BlackboardTextureMaterialParameter ) ) ||
type.TargetType.IsAssignableTo( typeof( SamplerStateParameter ) )
)
{
parameterType = new MaterialParameterType( type );
}
else if ( type.TargetType.IsAssignableTo( typeof( IBlackboardSubgraphParameter ) ) )
{
parameterType = new SubgraphParameterType( type );
}
if ( type.TargetType.IsAssignableTo( typeof( IBlackboardShaderFeatureParameter ) ) )
{
parameterType = new ShaderFeatureParameterType( type );
}
return parameterType;
}
}
public sealed class GroupParameterType : ClassBlackboardParameterType
{
protected override string DefaultBaseName => "Group";
public GroupParameterType( TypeDescription type ) : base( type )
{
}
}
public sealed class MaterialParameterType : ClassBlackboardParameterType
{
protected override string DefaultBaseName => "MaterialParameter";
public MaterialParameterType( TypeDescription type ) : base( type )
{
}
}
public sealed class SubgraphParameterType : ClassBlackboardParameterType
{
protected override string DefaultBaseName => Type.TargetType.IsAssignableTo( typeof( IBlackboardSubgraphInputParameter ) ) ? "SubgraphInput" : "SubgraphOutput";
public SubgraphParameterType( TypeDescription type ) : base( type )
{
}
}
public sealed class ShaderFeatureParameterType : ClassBlackboardParameterType
{
protected override string DefaultBaseName => "ShaderFeature";
public ShaderFeatureParameterType( TypeDescription type ) : base( type )
{
}
public override IBlackboardParameter CreateParameter( INodeGraph graph, string name = "" )
{
var sg = graph as ShaderGraphPlus;
var parameter = base.CreateParameter( graph );
if ( parameter is ShaderFeatureEnumParameter shaderFeatureEnum )
{
shaderFeatureEnum.Options.Add( new ShaderFeatureEnumOption() { Name = "A" } );
shaderFeatureEnum.Options.Add( new ShaderFeatureEnumOption() { Name = "B" } );
}
return parameter;
}
}
namespace ShaderGraphPlus.Nodes;
[Title( "Boolean Combo Switch" ), Category( "Utility/Logic" ), Icon( "alt_route" )]
[InternalNode]
public sealed class BooleanFeatureSwitchNode : ShaderNodePlus, IParameterNode, IBlackboardNode
{
[Hide, JsonIgnore, Browsable( false )]
public override Color NodeTitleColor { get; set; } = ShaderGraphPlusTheme.NodeHeaderColors.LogicNode;
[Hide, JsonIgnore, Browsable( false )]
public override string Title => $"F_{Feature.Name.ToUpper().Replace( " ", "_" )}";
[Hide, JsonIgnore, Browsable( false )]
public string Name => $"F_{Feature.Name.ToUpper().Replace( " ", "_" )}";
[Hide, Browsable( false )]
public Guid ParameterIdentifier { get; set; }
[Hide, JsonIgnore, Browsable( false )]
public ShaderFeatureBoolean Feature => GetFeature();
[Input, Hide]
[Title( "True" )]
public NodeInput InputTrue { get; set; }
[Input, Hide]
[Title( "False" )]
public NodeInput InputFalse { get; set; }
private ShaderFeatureBooleanParameter GetFeatureParameter()
{
if ( Graph is ShaderGraphPlus graph )
{
var parameter = graph.FindParameter<ShaderFeatureBooleanParameter>( ParameterIdentifier );
if ( parameter != null )
{
return parameter;
}
}
return new ShaderFeatureBooleanParameter();
}
private ShaderFeatureBoolean GetFeature()
{
var parameter = GetFeatureParameter();
if ( parameter.IsValid )
{
var featureBoolean = new ShaderFeatureBoolean
{
Name = parameter.Name,
Description = parameter.Description,
HeaderName = parameter.HeaderName,
};
return featureBoolean;
}
return new ShaderFeatureBoolean();
}
[Output, Hide]
public NodeResult.Func Result => ( GraphCompiler compiler ) =>
{
var inputs = new List<NodeInput>
{
InputTrue,
InputFalse
};
var preview = GetFeatureParameter().Preview;
var result = compiler.ResultFeatureSwitch( inputs, Feature, preview ? 1 : 0 );
return result.IsValid ? result : new NodeResult( ResultType.Float, $"1.0f" );
};
}
using Editor;
using ShaderGraphPlus.Nodes;
namespace ShaderGraphPlus;
[System.AttributeUsage( AttributeTargets.Class )]
internal class SubgraphOnlyAttribute : Attribute
{
public SubgraphOnlyAttribute()
{
}
}
public abstract class BaseNodePlus : IGraphNode
{
public event Action Changed;
[Hide, Browsable( false )]
public string Identifier { get; set; }
[JsonIgnore, Hide, Browsable( false )]
public virtual string Subtitle { get; }
[JsonIgnore, Hide, Browsable( false )]
public virtual DisplayInfo DisplayInfo { get; }
[JsonIgnore, Hide, Browsable( false )]
public bool CanClone => true;
[JsonIgnore, Hide, Browsable( false )]
public virtual bool CanRemove => true;
[JsonIgnore, Hide, Browsable( false )]
public virtual bool CanPreview => true;
[JsonIgnore, Hide, Browsable( false )]
public virtual bool CanAddToGraph => true;
[Hide, Browsable( false )]
public Vector2 Position { get; set; }
[JsonIgnore, Hide]
public INodeGraph _graph;
[JsonIgnore, Hide, Browsable( false )]
internal int PreviewID { get; set; }
[JsonIgnore, Hide, Browsable( false )]
public bool Processed { get; set; } = false;
[JsonIgnore, Hide, Browsable( false )]
public INodeGraph Graph
{
get => _graph;
set
{
_graph = value;
FilterInputsAndOutputs();
}
}
[JsonIgnore, Hide, Browsable( false )]
public Vector2 ExpandSize { get; set; }
[JsonIgnore, Hide, Browsable( false )]
public virtual bool AutoSize => false;
[JsonIgnore, Hide, Browsable( false )]
public virtual IEnumerable<IPlugIn> Inputs { get; protected set; }
[JsonIgnore, Hide, Browsable( false )]
public virtual IEnumerable<IPlugOut> Outputs { get; protected set; }
[JsonIgnore, Hide, Browsable( false )]
public string ErrorMessage { get; set; } = null;
[JsonIgnore, Hide, Browsable( false )]
public bool IsReachable => true;
[Hide, Browsable( false )]
public Dictionary<string, float> HandleOffsets { get; set; } = new();
public BaseNodePlus()
{
DisplayInfo = DisplayInfo.For( this );
NewIdentifier();
(Inputs, Outputs) = GetPlugs( this );
}
public override string ToString()
{
return $"{DisplayInfo.Fullname}.{Identifier}";
}
public void Update()
{
Changed?.Invoke();
}
public virtual void OnFrame()
{
}
public void ClearError()
{
HasError = false;
ErrorMessage = null;
}
public string NewIdentifier()
{
Identifier = Guid.NewGuid().ToString();
return Identifier;
}
public virtual NodeUI CreateUI( GraphView view )
{
return new NodeUI( view, this, false );
}
public Color GetNodeBodyTintColor( GraphView view )
{
return NodeBodyTintColor;
}
public Color GetNodeTitleColor( GraphView view )
{
return NodeTitleColor;
}
public virtual Menu CreateContextMenu( NodeUI node )
{
return null;
}
public virtual void DebugInfo( Menu menu )
{
var debugInfoHeading = menu.AddHeading( "Node Debug Info" );
menu.AddWidget( new Label( $"Node ID : {this.Identifier}" ) );
if ( this is IParameterNode blackboardSyncable )
{
menu.AddWidget( new Label( $"Blackboard ID : {blackboardSyncable.ParameterIdentifier}" ) ).AdjustSize();
}
menu.AddWidget( new Label( $"Preview ID : {this.PreviewID}" ) );
menu.AddWidget( new Label( $"IsReachable? : {this.IsReachable}" ) );
menu.AddWidget( new Label( $"CanPreview? : {this.CanPreview}" ) );
}
[JsonIgnore, Hide, Browsable( false )]
public virtual Pixmap Thumbnail { get; }
[JsonIgnore, Hide, Browsable( false )]
public virtual Color NodeBodyTintColor { get; set; } = Color.Parse( "#303030" )!.Value.Lighten( 2.0f );
[JsonIgnore, Hide, Browsable( false )]
public virtual Color NodeTitleColor { get; set; } = Color.Gray;
public virtual void OnPaint( Rect rect )
{
}
public virtual void OnDoubleClick( MouseEvent e )
{
}
[JsonIgnore, Hide, Browsable( false )]
public bool HasTitleBar => true;
[JsonIgnore, Hide, Browsable( false )]
public bool HasSubtitle => !string.IsNullOrWhiteSpace( Subtitle );
private bool _hasError;
[JsonIgnore, Hide, Browsable( false )]
public bool HasError
{
get => _hasError;
set
{
_hasError = value;
Update();
}
}
[JsonIgnore, Hide, Browsable( false )]
public bool HasWarning { get; set; } = false;
[System.AttributeUsage( AttributeTargets.Property )]
public class InputAttribute : Attribute
{
/// <summary>
/// Type of the port.
/// </summary>
public System.Type Type;
/// <summary>
/// Order of the port.
/// </summary>
public int Order;
public InputAttribute( Type type = null, int order = 0 )
{
Type = type;
Order = order;
}
}
[System.AttributeUsage( AttributeTargets.Property )]
public class InputDefaultAttribute : Attribute
{
public string Input;
public InputDefaultAttribute( string input )
{
Input = input;
}
}
[System.AttributeUsage( AttributeTargets.Property )]
public class OutputAttribute : Attribute
{
/// <summary>
/// Type of the port.
/// </summary>
public System.Type Type;
/// <summary>
/// Order of the port.
/// </summary>
public int Order;
public OutputAttribute( Type type = null, int order = 0 )
{
Type = type;
Order = order;
}
}
[System.AttributeUsage( AttributeTargets.Property )]
public class HideOutputAttribute : Attribute
{
public System.Type Type;
public HideOutputAttribute( Type type = null )
{
Type = type;
}
}
[System.AttributeUsage( AttributeTargets.Property )]
public class NodeValueEditorAttribute : Attribute
{
public string ValueName;
public NodeValueEditorAttribute( string valueName )
{
ValueName = valueName;
}
}
[System.AttributeUsage( AttributeTargets.Property )]
public class RangeAttribute : Attribute
{
public string Min;
public string Max;
public string Step;
public RangeAttribute( string min, string max, string step )
{
Min = min;
Max = max;
Step = step;
}
}
/// <summary>
/// Interface for nodes that want to setup anyting post node object deserializeation.
/// </summary>
public interface IInitializeNode
{
/// <summary>
/// Called after the node has been deserialized.
/// </summary>
public void InitializeNode();
}
/// <summary>
/// Connects a <see cref="NodeResult.Func"/> property from another node to the <see cref="NodeInput"/> property on this <see cref="BaseNodePlus"/> instance.
/// </summary>
/// <param name="targetInputName">The internal input name on this <see cref="BaseNodePlus"/> instance that will be connected to.</param>
/// <param name="sourceNodeOutputName">The internal output name of the source <see cref="BaseNodePlus"/> that the new connection is coming from.</param>
/// <param name="sourceNodeIdentifier">The Identifier of the souce <see cref="BaseNodePlus"/> that we are connecting from.</param>
public void ConnectNode( string targetInputName, string sourceNodeOutputName, string sourceNodeIdentifier )
{
if ( Graph == null )
{
throw new Exception( "Graph is null!!!" );
}
var graph = Graph as ShaderGraphPlus;
var targetOutputNode = graph.Nodes.Where( x => x.Identifier == sourceNodeIdentifier ).FirstOrDefault();
if ( targetOutputNode != null )
{
var plugIn = Inputs.Where( x => x.Identifier == targetInputName ).FirstOrDefault();
var targetOutputPlug = targetOutputNode.Outputs.FirstOrDefault( x => x.Identifier == sourceNodeOutputName );
if ( plugIn == null )
{
throw new Exception( $"Cannot find input with name '{targetInputName}' on node '{this}'" );
}
if ( targetOutputPlug == null )
{
throw new Exception( $"Cannot find output with name '{sourceNodeOutputName}' on node '{targetOutputNode}'" );
}
plugIn.ConnectedOutput = targetOutputPlug;
}
else
{
throw new Exception( $"Cannot find node with Identifier '{sourceNodeIdentifier}'" );
}
}
public static (IEnumerable<IPlugIn> Inputs, IEnumerable<IPlugOut> Outputs) GetPlugs( BaseNodePlus node )
{
var type = node.GetType();
var inputs = new List<BasePlugIn>();
var outputs = new List<BasePlugOut>();
var inputProperties = type.GetProperties().OrderBy( x =>
(x.GetCustomAttribute<InputAttribute>() is InputAttribute input) ? input.Order : 0 );
foreach ( var propertyInfo in inputProperties )
{
if ( propertyInfo.GetCustomAttribute<InputAttribute>() is { } inputAttrib )
{
inputs.Add( new BasePlugIn( node, new( propertyInfo ), inputAttrib.Type ?? typeof( object ) ) );
}
}
var outputProperties = type.GetProperties().OrderBy( x =>
(x.GetCustomAttribute<OutputAttribute>() is OutputAttribute output) ? output.Order : 0 );
foreach ( var propertyInfo in outputProperties )
{
if ( propertyInfo.GetCustomAttribute<OutputAttribute>() is { } outputAttrib )
{
outputs.Add( new BasePlugOut( node, new( propertyInfo ), outputAttrib.Type ?? typeof( object ) ) );
}
}
return (inputs, outputs);
}
private void FilterInputsAndOutputs()
{
if ( _graph is not null )
{
if ( Graph is ShaderGraphPlus sgp && !sgp.IsSubgraph && this is not BooleanFeatureSwitchNode && this is IParameterNode )
{
Inputs = new List<IPlugIn>();
}
}
}
}
public record BasePlug( BaseNodePlus Node, PlugInfo Info, Type Type ) : IPlug
{
IGraphNode IPlug.Node => Node;
Type IPlug.Type { get; set; } = Type;
public string Identifier => Info.Name;
public DisplayInfo DisplayInfo => Info.DisplayInfo;
public ValueEditor CreateEditor( NodeUI node, NodePlug plug )
{
var editor = Info.CreateEditor( node, plug, Type );
if ( editor is not null ) return editor;
// Default
{
var defaultEditor = new DefaultEditor( plug );
}
return null;
}
public Menu CreateContextMenu( NodeUI node, NodePlug plug )
{
return null;
}
public void OnDoubleClick( NodeUI node, NodePlug plug, MouseEvent e )
{
}
public bool ShowLabel => true;
public bool AllowStretch => true;
public bool ShowConnection => IsReachable;
public bool InTitleBar => false;
public bool IsReachable
{
get
{
var conditional = Info.Property?.GetCustomAttribute<ConditionalVisibilityAttribute>();
if ( conditional is not null )
{
if ( conditional.TestCondition( Node.GetSerialized() ) ) return false;
}
return true;
}
}
public string ErrorMessage => null;
public override string ToString()
{
return $"{Node.Identifier}.{Identifier}";
}
}
public record BasePlugIn( BaseNodePlus Node, PlugInfo Info, Type Type ) : BasePlug( Node, Info, Type ), IPlugIn
{
IPlugOut IPlugIn.ConnectedOutput
{
get
{
if ( Info.Property is null )
{
return Info.ConnectedPlug;
}
if ( Info.Type != typeof( NodeInput ) )
{
return null;
}
var value = Info.GetInput( Node );
if ( !value.IsValid )
{
return null;
}
var node = ((ShaderGraphPlus)Node.Graph).FindNode( value.Identifier );
var output = node?.Outputs
.FirstOrDefault( x => x.Identifier == value.Output );
return output;
}
set
{
var property = Info.Property;
if ( property is null )
{
Info.ConnectedPlug = value;
return;
}
if ( property.PropertyType != typeof( NodeInput ) )
{
return;
}
if ( value is null )
{
property.SetValue( Node, default( NodeInput ) );
return;
}
if ( value is not BasePlug fromPlug )
{
return;
}
property.SetValue( Node, new NodeInput
{
Identifier = fromPlug.Node.Identifier,
Output = fromPlug.Identifier
} );
}
}
public float? GetHandleOffset( string name )
{
if ( Node.HandleOffsets.TryGetValue( name, out var value ) )
{
return value;
}
return null;
}
public void SetHandleOffset( string name, float? value )
{
if ( value is null ) Node.HandleOffsets.Remove( name );
else Node.HandleOffsets[name] = value.Value;
}
}
public record BasePlugOut( BaseNodePlus Node, PlugInfo Info, Type Type ) : BasePlug( Node, Info, Type ), IPlugOut;
public class PlugInfo
{
public Guid Id { get; set; }
public string Name { get; set; }
public Type Type { get; set; }
public DisplayInfo DisplayInfo { get; set; }
public PropertyInfo Property { get; set; } = null;
public IPlugOut ConnectedPlug { get; set; } = null;
public PlugInfo()
{
DisplayInfo = new();
}
public PlugInfo( PropertyInfo property )
{
Name = property.Name;
Type = property.PropertyType;
var info = DisplayInfo.ForMember( Type );
info.Name = property.Name;
var titleAttr = property.GetCustomAttribute<TitleAttribute>();
if ( titleAttr is not null )
{
info.Name = titleAttr.Value;
}
var descriptionAttr = property.GetCustomAttribute<DescriptionAttribute>();
if ( descriptionAttr is not null )
{
info.Description = descriptionAttr.Value;
}
DisplayInfo = info;
Property = property;
}
public NodeInput GetInput( BaseNodePlus node )
{
if ( Property is not null )
{
return (NodeInput)Property.GetValue( node )!;
}
return default;
}
public ValueEditor CreateEditor( NodeUI node, NodePlug plug, Type type )
{
var editor = Property?.GetCustomAttribute<BaseNodePlus.NodeValueEditorAttribute>();
if ( editor is not null )
{
if ( type == typeof( int ) )
{
var slider = new IntValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
slider.Bind( "Value" ).From( node.Node, editor.ValueName );
var range = Property.GetCustomAttribute<BaseNodePlus.RangeAttribute>();
if ( range != null )
{
slider.Bind( "Min" ).From( node.Node, range.Min );
slider.Bind( "Max" ).From( node.Node, range.Max );
}
else if ( Property.GetCustomAttribute<MinMaxAttribute>() is MinMaxAttribute minMax )
{
slider.Min = (int)minMax.MinValue;
slider.Max = (int)minMax.MaxValue;
}
return slider;
}
if ( type == typeof( float ) )
{
var slider = new FloatValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
slider.Bind( "Value" ).From( node.Node, editor.ValueName );
var range = Property.GetCustomAttribute<BaseNodePlus.RangeAttribute>();
if ( range != null )
{
slider.Bind( "Min" ).From( node.Node, range.Min );
slider.Bind( "Max" ).From( node.Node, range.Max );
slider.Bind( "Step" ).From( node.Node, range.Step );
}
else if ( Property.GetCustomAttribute<MinMaxAttribute>() is MinMaxAttribute minMax )
{
slider.Min = minMax.MinValue;
slider.Max = minMax.MaxValue;
}
return slider;
}
if ( type == typeof( Color ) )
{
var slider = new ColorValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
slider.Bind( "Value" ).From( node.Node, editor.ValueName );
return slider;
}
if ( type == typeof( Gradient ) )
{
var slider = new GradientValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
slider.Bind( "Value" ).From( node.Node, editor.ValueName );
return slider;
}
}
return null;
}
}
namespace ShaderGraphPlus.Nodes;
/// <summary>
///
/// </summary>
[Title( "Pixel Plot" ), Category( "Effects" ), Icon( "grid_on" )]
public sealed class PixelPlotNode : ShaderNodePlus
{
[JsonIgnore, Hide, Browsable( false )]
public override Color NodeTitleColor => ShaderGraphPlusTheme.NodeHeaderColors.FunctionNode;
[Hide]
public string PixelPlot => @"
float4 PixelPlot( in Texture2D vColorTex, in SamplerState sSampler, float2 vUv , float2 vGridSize , float flBoarderThickness)
{
float2 vGridBlock = 1.0f / vGridSize;
float2 vUvGrid = floor( vUv * vGridSize ) / vGridSize; // Divide By Gridsize so that uvspace is clamped to 0 to 1.
float2 vGridBoarder = step( 0.5f - flBoarderThickness, frac( vUv / vGridBlock ) ) *
step( frac( vUv / vGridBlock ), 0.5f + flBoarderThickness );
return vColorTex.Sample( sSampler, vUvGrid ) * ( vGridBoarder.x * vGridBoarder.y );
}
";
/// <summary>
/// Texture object to apply the effect to.
/// </summary>
[Title( "Texture2D" )]
[Input( typeof( Texture ) )]
[Hide]
public NodeInput Texture2D { get; set; }
/// <summary>
/// Coordinates to sample this texture
/// </summary>
[Title( "Coordinates" )]
[Input( typeof( Vector2 ) )]
[Hide]
public NodeInput Coords { get; set; }
/// <summary>
/// How the effect is filtered and wrapped when sampled
/// </summary>
[Title( "Sampler" )]
[Input( typeof( Sampler ) )]
[Hide]
public NodeInput Sampler { get; set; }
[Input( typeof( Vector2 ) )]
[Hide]
public NodeInput GridSize { get; set; }
[Input( typeof( float ) )]
[Hide]
public NodeInput BoarderThickness { get; set; }
[InlineEditor( Label = false ), Group( "Sampler" )]
[ShowIf( nameof( ShowDefaultSamplerState ), true )]
public Sampler SamplerState { get; set; } = new Sampler();
[InputDefault( nameof( GridSize ) )]
public Vector2 DefaultGridSize { get; set; } = new Vector2( 24.0f, 24.0f );
[InputDefault( nameof( BoarderThickness ) )]
public float DefaultBoarderThickness { get; set; } = 0.420f;
[JsonIgnore, Hide, Browsable( false )]
private bool ShowDefaultSamplerState { get; set; } = false;
[Output( typeof( Vector4 ) ), Title( "Result" )]
[Hide]
public NodeResult.Func Result => ( GraphCompiler compiler ) =>
{
var coordsResult = compiler.Result( Coords );
var textureResult = compiler.Result( Texture2D );
var samplerResult = compiler.ResultSamplerOrDefault( Sampler, SamplerState );
var gridResult = compiler.ResultOrDefault( GridSize, DefaultGridSize );
var boarderThicknessResult = compiler.ResultOrDefault( BoarderThickness, DefaultBoarderThickness );
ShowDefaultSamplerState = !Sampler.IsValid;
if ( !textureResult.IsValid )
{
return NodeResult.MissingInput( "Texture2D" );
}
else if ( textureResult.ResultType is not ResultType.Texture2D )
{
return NodeResult.Error( $"Input to TexObject is not a texture object!" );
}
string func = compiler.RegisterHLSLFunction( PixelPlot, "PixelPlot" );
string funcCall = compiler.ResultHLSLFunction( func, $"{textureResult}, {samplerResult}, {(coordsResult.IsValid ? $"{coordsResult.Cast( 2 )}" : "i.vTextureCoords.xy")}, {gridResult}, {boarderThicknessResult}" );
return new NodeResult( ResultType.Vector4, funcCall );
};
}
using Facepunch.ActionGraphs;
namespace ShaderGraphPlus.Nodes;
/// <summary>
/// Get the dimensions of a Texture2D.
/// </summary>
[Title( "Get Texture2D Dimensions" ), Category( "Textures" ), Icon( "straighten" )]
public sealed class GetTexture2DDimensions : ShaderNodePlus
{
[JsonIgnore, Hide, Browsable( false )]
public override Color NodeTitleColor => ShaderGraphPlusTheme.NodeHeaderColors.FunctionNode;
[Title( "Texture2D" )]
[Input( typeof( Texture ) )]
[Hide]
public NodeInput TextureInput { get; set; }
[Title( "Mip Level" )]
[Input( typeof( int ) )]
[Hide]
public NodeInput MipLevelInput { get; set; }
[JsonIgnore, Hide]
public override bool CanPreview => false;
[InputDefault( nameof( MipLevelInput ) )]
public int DefaultMipLevel { get; set; } = 0;
public GetTexture2DDimensions()
{
ExpandSize = new Vector2( 8, 0 );
}
[Output( typeof( Vector2 ) )]
[Title( "Size" )]
[Hide]
public NodeResult.Func Result => ( GraphCompiler compiler ) =>
{
ClearError();
var textureResult = compiler.Result( TextureInput );
if ( !textureResult.IsValid )
{
return NodeResult.MissingInput( "Texture2D" );
}
else if ( textureResult.ResultType != ResultType.Texture2D )
{
return NodeResult.IncorrectInputType( "Texture2D", ResultType.Texture2D );
}
var miplevelResult = compiler.ResultOrDefault( MipLevelInput, DefaultMipLevel );
return new NodeResult( ResultType.Vector2, $"TextureDimensions2D({textureResult}, {miplevelResult})", constant: false );
};
}
namespace ShaderGraphPlus;
[System.AttributeUsage( AttributeTargets.Property )]
internal sealed class ShaderFeatureReferenceAttribute : Attribute
{
}
public abstract class ShaderFeatureBase : IValid
{
/// <summary>
/// Name of this feature.
/// </summary>
public string Name { get; set; }
/// <summary>
/// What this feature does.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Header Name of this Feature that shows up in the Material Editor.
/// </summary>
public string HeaderName { get; set; }
[Hide, JsonIgnore, Browsable( false )]
public virtual bool IsValid => throw new NotImplementedException();
public ShaderFeatureBase()
{
Name = "";
Description = "";
HeaderName = "";
}
public string GetFeatureString()
{
return $"F_{Name.ToUpper().Replace( " ", "_" )}";
}
public string GetDynamicComboString()
{
return $"D_{Name.ToUpper().Replace( " ", "_" )}";
}
public string GetStaticComboString()
{
return $"S_{Name.ToUpper().Replace( " ", "_" )}";
}
public virtual string GetOptionRangeString()
{
return $"";
}
}
public sealed class ShaderFeatureBoolean : ShaderFeatureBase
{
[Hide, JsonIgnore, Browsable( false )]
public override bool IsValid => !string.IsNullOrWhiteSpace( Name );
public ShaderFeatureBoolean() : base()
{
}
public override int GetHashCode()
{
return System.HashCode.Combine( Name, Description, HeaderName );
}
public override string GetOptionRangeString()
{
return "0..1";
}
}
public sealed class ShaderFeatureEnumOption : IValid
{
[Hide]
public Guid Id { get; set; }
[KeyProperty]
public string Name { get; set; }
[Hide, JsonIgnore]
public bool IsValid => !string.IsNullOrWhiteSpace( Name );
public ShaderFeatureEnumOption()
{
Id = Guid.NewGuid();
}
public override string ToString()
{
return Name;
}
public override int GetHashCode()
{
return System.HashCode.Combine( Id, Name );
}
}
public sealed class ShaderFeatureEnum : ShaderFeatureBase
{
[Hide, JsonIgnore, Browsable( false )]
public override bool IsValid => !string.IsNullOrWhiteSpace( Name ) && Options.All( x => !string.IsNullOrWhiteSpace( x.Name ) );
/// <summary>
/// Options of your feature. Must have no special characters. Note : all lowercase letters will be converted to uppercase.
/// </summary>
public List<ShaderFeatureEnumOption> Options { get; set; }
public ShaderFeatureEnum() : base()
{
Options = new List<ShaderFeatureEnumOption>();
}
public override int GetHashCode()
{
var hashcode = System.HashCode.Combine( Name, Description, HeaderName );
foreach ( var option in Options )
{
hashcode += option.GetHashCode();
}
return hashcode;
}
public override string GetOptionRangeString()
{
return $"0..{Options.Count - 1}";
}
}
using Editor;
using static ShaderGraphPlus.GraphCompiler;
namespace ShaderGraphPlus;
public class Output : Widget
{
private IssueListView _issueListView;
private List<GraphCompiler.GraphIssue> _graphIssues;
public List<GraphCompiler.GraphIssue> GraphIssues
{
get => _graphIssues;
set
{
_graphIssues = value;
//_issueListView.AddItems( value.Cast<object>() );
_issueListView.SetItems( value.Cast<object>() );
}
}
public Action<BaseNodePlus> OnNodeSelected { get; set; }
public void ClearErrors()
{
foreach ( var error in _issueListView.Items.Where( x => x is GraphIssue issue && issue.IsWarning == false ) )
{
_issueListView.RemoveItem( error );
}
}
internal void ClearWarnings()
{
foreach ( var warning in _issueListView.Items.Where( x => x is GraphIssue issue && issue.IsWarning == true ) )
{
_issueListView.RemoveItem( warning );
}
}
public Output( Widget parent ) : base( parent )
{
Name = "Output";
WindowTitle = "Output";
SetWindowIcon( "notes" );
Layout = Layout.Column();
_issueListView = new( this );
Layout.Add( _issueListView );
}
}
public class IssueListView : ListView
{
private Output _output;
public IssueListView( Output parent ) : base( parent )
{
_output = parent;
ItemActivated = ( a ) =>
{
if ( a is not GraphCompiler.GraphIssue issue )
return;
if ( issue.Node != null && issue.Node is not DummyNode )
{
_output.OnNodeSelected?.Invoke( issue.Node );
}
};
ItemContextMenu = OpenItemContextMenu;
ItemSize = new Vector2( 0, 48 );
ItemSpacing = 0;
Margin = 0;
}
private void OpenItemContextMenu( object item )
{
if ( item is GraphCompiler.GraphIssue issue )
{
OnOpenItemContextMenuError( item, issue );
}
}
private void OnOpenItemContextMenuError( object item, GraphIssue error )
{
var m = new Menu();
if ( error.Node != null && error.Node is not DummyNode )
{
var nodeName = DisplayInfo.ForType( error.Node.GetType() ).Name;
m.AddOption( "Go to Error", "arrow_upward", () => _output.OnNodeSelected?.Invoke( error.Node ) );
m.AddOption( "Copy Error", "content_copy", () => EditorUtility.Clipboard.Copy( $"{error.Message}\n{nodeName} #{error.Node.Identifier}" ) );
}
else
{
m.AddOption( "Copy Error", "content_copy", () => EditorUtility.Clipboard.Copy( $"{error.Message}" ) );
}
m.OpenAt( Editor.Application.CursorPosition );
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.WindowBackground );
Paint.DrawRect( LocalRect );
base.OnPaint();
}
public void OnPaintError( VirtualWidget item, GraphCompiler.GraphIssue error )
{
var color = Theme.Red;
Paint.SetBrush( color.WithAlpha( Paint.HasMouseOver ? 0.1f : 0.03f ) );
Paint.ClearPen();
Paint.DrawRect( item.Rect.Shrink( 0, 1 ) );
Paint.Antialiasing = true;
Paint.SetPen( color.WithAlpha( Paint.HasMouseOver ? 1 : 0.7f ), 3.0f );
Paint.ClearBrush();
var iconRect = item.Rect.Shrink( 12, 0 );
iconRect.Width = 24;
Paint.DrawIcon( iconRect, "error", 24 );
var rect = item.Rect.Shrink( 48, 8, 0, 8 );
Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 1 : 0.8f ), 3.0f );
Paint.DrawText( rect, error.Message, (error.Node != null ? TextFlag.LeftTop : TextFlag.LeftCenter) | TextFlag.SingleLine );
if ( error.Node != null && error.Node is not DummyNode )
{
var nodeName = DisplayInfo.ForType( error.Node.GetType() ).Name;
Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 0.5f : 0.4f ), 3.0f );
Paint.DrawText( rect, $"{nodeName}", TextFlag.LeftBottom | TextFlag.SingleLine );
}
}
public void OnPaintWarning( VirtualWidget item, GraphCompiler.GraphIssue warning )
{
var color = Theme.Yellow;
Paint.SetBrush( color.WithAlpha( Paint.HasMouseOver ? 0.1f : 0.03f ) );
Paint.ClearPen();
Paint.DrawRect( item.Rect.Shrink( 0, 1 ) );
Paint.Antialiasing = true;
Paint.SetPen( color.WithAlpha( Paint.HasMouseOver ? 1 : 0.7f ), 3.0f );
Paint.ClearBrush();
var iconRect = item.Rect.Shrink( 12, 0 );
iconRect.Width = 24;
Paint.DrawIcon( iconRect, "error", 24 );
var rect = item.Rect.Shrink( 48, 8, 0, 8 );
Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 1 : 0.8f ), 3.0f );
Paint.DrawText( rect, warning.Message, (warning.Node != null ? TextFlag.LeftTop : TextFlag.LeftCenter) | TextFlag.SingleLine );
if ( warning.Node != null )
{
var nodeName = DisplayInfo.ForType( warning.Node.GetType() ).Name;
Paint.SetPen( Color.White.WithAlpha( Paint.HasMouseOver ? 0.5f : 0.4f ), 3.0f );
Paint.DrawText( rect, $"{nodeName}", TextFlag.LeftBottom | TextFlag.SingleLine );
}
}
protected override void PaintItem( VirtualWidget item )
{
if ( item.Object is GraphCompiler.GraphIssue issue )
{
if ( issue.IsWarning )
{
OnPaintWarning( item, issue );
}
else
{
OnPaintError( item, issue );
}
}
}
}
using Editor;
namespace ShaderGraphPlus;
// TODO : Add Line Numbers to the left of the text area. Also add an option to toggle LineNumbers on or off.
public class TextView : Widget
{
public Action TextChanged;
private string _contents = "";
private TextEdit _textEdit;
public TextView( Widget parent, string windowTitle, string text ) : base( parent )
{
Name = windowTitle;
WindowTitle = windowTitle;
_contents = text;
Parent = parent;
SetWindowIcon( "edit" );
Layout = Layout.Row();
// TODO
var LineNumbers = Layout.AddRow( 1 );
LineNumbers.AddSpacingCell( 16f );
//Layout.Add( LineNumbers );
_textEdit = new TextEdit( this );
_textEdit.ReadOnly = true;
_contents = text;
_textEdit.PlainText = _contents;
_textEdit.VerticalScrollbarMode = ScrollbarMode.On;
_textEdit.TextChanged += x =>
{
TextChanged?.Invoke();
};
_textEdit.SetStyles( $"font-size: 12px; font-weight: regular; color: {Theme.TextControl.Hex};" );
Layout.Add( _textEdit );
}
public string GetTextContents()
{
return _contents;
}
public void SetTextContents( string text )
{
if ( !string.IsNullOrWhiteSpace( text ) )
{
text.ReplaceLineEndings();
text = text.Replace( " ", "\t" );
}
_contents = text;
_textEdit.PlainText = _contents;
}
}
namespace Editor.ShaderGraphExtras;
public class TextureNodeType : ClassNodeType
{
string ImagePath;
public TextureNodeType( TypeDescription type, string imagePath ) : base( type )
{
ImagePath = imagePath;
}
public override INode CreateNode( IGraph graph )
{
var node = base.CreateNode( graph );
if ( node is ITextureParameterNode textureNode )
{
textureNode.Image = ImagePath;
}
return node;
}
}
public class ClassNodeType : INodeType
{
public virtual string Identifier => Type.FullName;
public TypeDescription Type { get; }
public DisplayInfo DisplayInfo { get; protected set; }
public Menu.PathElement[] Path => Menu.GetSplitPath( DisplayInfo );
public ClassNodeType( TypeDescription type )
{
Type = type;
if ( Type is not null )
DisplayInfo = DisplayInfo.ForType( Type.TargetType );
else
DisplayInfo = new DisplayInfo();
}
public bool TryGetInput( Type valueType, out string name )
{
var property = Type.Properties
.Select( x => (Property: x, Attrib: x.GetCustomAttribute<BaseNode.InputAttribute>()) )
.Where( x => x.Attrib != null )
.FirstOrDefault( x => x.Attrib.Type?.IsAssignableFrom( valueType ) ?? true )
.Property;
name = property?.Name;
return name is not null;
}
public bool TryGetOutput( Type valueType, out string name )
{
var property = Type.Properties
.Select( x => (Property: x, Attrib: x.GetCustomAttribute<BaseNode.OutputAttribute>()) )
.Where( x => x.Attrib != null )
.FirstOrDefault( x => x.Attrib.Type?.IsAssignableTo( valueType ) ?? true )
.Property;
name = property?.Name;
return name is not null;
}
public virtual INode CreateNode( IGraph graph )
{
var node = Type.Create<BaseNode>();
node.Graph = graph;
return node;
}
}
public class SubgraphNodeType : ClassNodeType
{
public override string Identifier => AssetPath;
string AssetPath { get; }
public SubgraphNodeType( string assetPath, TypeDescription type ) : base( type )
{
AssetPath = assetPath;
}
public void SetDisplayInfo( ShaderGraph subgraph )
{
var info = DisplayInfo;
if ( !string.IsNullOrEmpty( subgraph.Title ) )
info.Name = subgraph.Title;
else
info.Name = System.IO.Path.GetFileNameWithoutExtension( AssetPath );
if ( !string.IsNullOrEmpty( subgraph.Description ) )
info.Description = subgraph.Description;
if ( !string.IsNullOrEmpty( subgraph.Icon ) )
info.Icon = subgraph.Icon;
if ( !string.IsNullOrEmpty( subgraph.Category ) )
info.Group = subgraph.Category;
DisplayInfo = info;
}
public override INode CreateNode( IGraph graph )
{
var node = base.CreateNode( graph );
if ( node is SubgraphNode subgraphNode )
{
subgraphNode.SubgraphPath = AssetPath;
subgraphNode.OnNodeCreated();
}
return node;
}
}
namespace Editor.ShaderGraphExtras;
public static class ShaderTemplate
{
// Cache for template types to avoid expensive reflection lookups
private static readonly Dictionary<string, Type> _templateTypeCache = new();
public static string LoadTemplate( string templatePath )
{
if ( string.IsNullOrWhiteSpace( templatePath ) )
return SurfaceTemplate.Code;
var templateType = FindTemplateType( templatePath );
if ( templateType != null )
{
var property = templateType.GetProperty( "Code", BindingFlags.Public | BindingFlags.Static );
if ( property != null && property.PropertyType == typeof( string ) )
{
var templateCode = (string)property.GetValue( null );
if ( !string.IsNullOrWhiteSpace( templateCode ) )
{
return templateCode;
}
}
}
Log.Warning( $"Shader template at '{templatePath}' not found, using default template" );
return SurfaceTemplate.Code;
}
/// <summary>
/// Find a template type by searching for classes with a Code property in the Editor.ShaderGraph namespace
/// </summary>
private static Type FindTemplateType( string templatePath )
{
if ( string.IsNullOrWhiteSpace( templatePath ) )
return null;
// Check cache first
if ( _templateTypeCache.TryGetValue( templatePath, out var cachedType ) )
return cachedType;
// Extract class name from file path as a hint
string className = Path.GetFileNameWithoutExtension( templatePath );
string[] searchNamespaces = [ "Editor.ShaderGraphExtras" ];
try
{
foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
{
try
{
// First try exact class name match in known namespaces
foreach ( var ns in searchNamespaces )
{
var templateType = assembly.GetType( $"{ns}.{className}" );
if ( templateType != null && HasCodeProperty( templateType ) )
{
_templateTypeCache[templatePath] = templateType;
return templateType;
}
}
// Search types in known namespaces for one whose name contains the filename
foreach ( var type in assembly.GetTypes() )
{
if ( !type.IsClass || !type.IsPublic || !HasCodeProperty( type ) )
continue;
if ( searchNamespaces.Contains( type.Namespace ) &&
type.Name.Contains( className, StringComparison.OrdinalIgnoreCase ) )
{
_templateTypeCache[templatePath] = type;
return type;
}
}
// Fallback: search all types for exact class name match
foreach ( var type in assembly.GetTypes() )
{
if ( type.Name == className && HasCodeProperty( type ) )
{
_templateTypeCache[templatePath] = type;
return type;
}
}
}
catch { continue; }
}
}
catch { }
// Cache null result to avoid repeated failed lookups
_templateTypeCache[templatePath] = null;
return null;
}
/// <summary>
/// Check if a type has a static Code property that returns a string
/// </summary>
private static bool HasCodeProperty( Type type )
{
var property = type.GetProperty( "Code", BindingFlags.Public | BindingFlags.Static );
return property != null && property.PropertyType == typeof( string );
}
public static Dictionary<string, bool> GetTemplateFeatures( string templatePath )
{
var features = new Dictionary<string, bool>();
if ( string.IsNullOrWhiteSpace( templatePath ) )
return features;
var templateType = FindTemplateType( templatePath );
if ( templateType != null )
{
var property = templateType.GetProperty( "Features", BindingFlags.Public | BindingFlags.Static );
if ( property != null && property.PropertyType == typeof( Dictionary<string, bool> ) )
{
var templateFeatures = (Dictionary<string, bool>)property.GetValue( null );
if ( templateFeatures != null )
{
return templateFeatures;
}
}
}
return features;
}
public static Dictionary<string, bool> GetShadingModelFeatures( string shadingModelPath )
{
var features = new Dictionary<string, bool>();
if ( string.IsNullOrWhiteSpace( shadingModelPath ) )
return features;
var shadingModelType = FindTemplateType( shadingModelPath );
if ( shadingModelType != null )
{
var property = shadingModelType.GetProperty( "Features", BindingFlags.Public | BindingFlags.Static );
if ( property != null && property.PropertyType == typeof( Dictionary<string, bool> ) )
{
var shadingModelFeatures = (Dictionary<string, bool>)property.GetValue( null );
if ( shadingModelFeatures != null )
{
return shadingModelFeatures;
}
}
}
return features;
}
/// <summary>
/// Load a custom shading model from a class with static Code and Include properties.
/// Code: The pixel shader return statement (e.g., "return ShadingModelToon::Shade( m );")
/// Include: Optional HLSL include path for the shading model implementation
/// </summary>
public static (string Code, string Include) LoadShadingModel( string shadingModelPath )
{
if ( string.IsNullOrWhiteSpace( shadingModelPath ) )
return ("return ShadingModelStandard::Shade( m );", null);
var shadingModelType = FindTemplateType( shadingModelPath );
if ( shadingModelType != null )
{
var codeProperty = shadingModelType.GetProperty( "Code", BindingFlags.Public | BindingFlags.Static );
if ( codeProperty != null && codeProperty.PropertyType == typeof( string ) )
{
var shadingModelCode = (string)codeProperty.GetValue( null );
if ( !string.IsNullOrWhiteSpace( shadingModelCode ) )
{
// Check for optional Include property
string includePath = null;
var includeProperty = shadingModelType.GetProperty( "Include", BindingFlags.Public | BindingFlags.Static );
if ( includeProperty != null && includeProperty.PropertyType == typeof( string ) )
{
includePath = (string)includeProperty.GetValue( null );
}
return (shadingModelCode, includePath);
}
}
}
Log.Warning( $"Shading model at '{shadingModelPath}' not found, using default Lit shading model" );
return ("return ShadingModelStandard::Shade( m );", null);
}
[Function( "ColorBurn_blend" )]
public static string ColorBurn_blend => @"
float ColorBurn_blend( float a, float b )
{
if ( a >= 1.0f ) return 1.0f;
if ( b <= 0.0f ) return 0.0f;
return 1.0f - saturate( ( 1.0f - a ) / b );
}
float3 ColorBurn_blend( float3 a, float3 b )
{
return float3(
ColorBurn_blend( a.r, b.r ),
ColorBurn_blend( a.g, b.g ),
ColorBurn_blend( a.b, b.b )
);
}
float4 ColorBurn_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
ColorBurn_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? ColorBurn_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "LinearBurn_blend" )]
public static string LinearBurn_blend => @"
float LinearBurn_blend( float a, float b )
{
return max( 0.0f, a + b - 1.0f );
}
float3 LinearBurn_blend( float3 a, float3 b )
{
return float3(
LinearBurn_blend( a.r, b.r ),
LinearBurn_blend( a.g, b.g ),
LinearBurn_blend( a.b, b.b )
);
}
float4 LinearBurn_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
LinearBurn_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? LinearBurn_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "ColorDodge_blend" )]
public static string ColorDodge_blend => @"
float ColorDodge_blend( float a, float b )
{
if ( a <= 0.0f ) return 0.0f;
if ( b >= 1.0f ) return 1.0f;
return saturate( a / ( 1.0f - b ) );
}
float3 ColorDodge_blend( float3 a, float3 b )
{
return float3(
ColorDodge_blend( a.r, b.r ),
ColorDodge_blend( a.g, b.g ),
ColorDodge_blend( a.b, b.b )
);
}
float4 ColorDodge_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
ColorDodge_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? ColorDodge_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "LinearDodge_blend" )]
public static string LinearDodge_blend => @"
float LinearDodge_blend( float a, float b )
{
return min( 1.0f, a + b );
}
float3 LinearDodge_blend( float3 a, float3 b )
{
return float3(
LinearDodge_blend( a.r, b.r ),
LinearDodge_blend( a.g, b.g ),
LinearDodge_blend( a.b, b.b )
);
}
float4 LinearDodge_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
LinearDodge_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? LinearDodge_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "Overlay_blend" )]
public static string Overlay_blend => @"
float Overlay_blend( float a, float b )
{
if ( a <= 0.5f )
return 2.0f * a * b;
else
return 1.0f - 2.0f * ( 1.0f - a ) * ( 1.0f - b );
}
float3 Overlay_blend( float3 a, float3 b )
{
return float3(
Overlay_blend( a.r, b.r ),
Overlay_blend( a.g, b.g ),
Overlay_blend( a.b, b.b )
);
}
float4 Overlay_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
Overlay_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? Overlay_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "SoftLight_blend" )]
public static string SoftLight_blend => @"
float SoftLight_blend( float a, float b )
{
if ( b <= 0.5f )
return 2.0f * a * b + a * a * ( 1.0f * 2.0f * b );
else
return sqrt( a ) * ( 2.0f * b - 1.0f ) + 2.0f * a * (1.0f - b);
}
float3 SoftLight_blend( float3 a, float3 b )
{
return float3(
SoftLight_blend( a.r, b.r ),
SoftLight_blend( a.g, b.g ),
SoftLight_blend( a.b, b.b )
);
}
float4 SoftLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
SoftLight_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? SoftLight_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "HardLight_blend" )]
public static string HardLight_blend => @"
float HardLight_blend( float a, float b )
{
if(b <= 0.5f)
return 2.0f * a * b;
else
return 1.0f - 2.0f * (1.0f - a) * (1.0f - b);
}
float3 HardLight_blend( float3 a, float3 b )
{
return float3(
HardLight_blend( a.r, b.r ),
HardLight_blend( a.g, b.g ),
HardLight_blend( a.b, b.b )
);
}
float4 HardLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
HardLight_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? HardLight_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "VividLight_blend" )]
public static string VividLight_blend => @"
float VividLight_blend( float a, float b )
{
if ( b <= 0.5f )
{
b *= 2.0f;
if ( a >= 1.0f ) return 1.0f;
if ( b <= 0.0f ) return 0.0f;
return 1.0f - saturate( ( 1.0f - a ) / b );
}
else
{
b = 2.0f * ( b - 0.5f );
if ( a <= 0.0f ) return 0.0f;
if ( b >= 1.0f ) return 1.0f;
return saturate( a / ( 1.0f - b ) );
}
}
float3 VividLight_blend( float3 a, float3 b )
{
return float3(
VividLight_blend( a.r, b.r ),
VividLight_blend( a.g, b.g ),
VividLight_blend( a.b, b.b )
);
}
float4 VividLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
VividLight_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? VividLight_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "LinearLight_blend" )]
public static string LinearLight_blend => @"
float LinearLight_blend( float a, float b )
{
if ( b <= 0.5f )
{
b *= 2.0f;
return max( 0.0f, a + b - 1.0f );
}
else
{
b = 2.0f * ( b - 0.5f );
return min( 1.0f, a + b );
}
}
float3 LinearLight_blend( float3 a, float3 b )
{
return float3(
LinearLight_blend( a.r, b.r ),
LinearLight_blend( a.g, b.g ),
LinearLight_blend( a.b, b.b )
);
}
float4 LinearLight_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
LinearLight_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? LinearLight_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "HardMix_blend" )]
public static string HardMix_blend => @"
float HardMix_blend( float a, float b )
{
if(a + b >= 1.0f) return 1.0f;
else return 0.0f;
}
float3 HardMix_blend( float3 a, float3 b )
{
return float3(
HardMix_blend( a.r, b.r ),
HardMix_blend( a.g, b.g ),
HardMix_blend( a.b, b.b )
);
}
float4 HardMix_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
HardMix_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? HardMix_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "Divide_blend" )]
public static string Divide_blend => @"
float Divide_blend( float a, float b )
{
if( b > 0.0f )
return saturate( a / b );
else
return 0.0f;
}
float3 Divide_blend( float3 a, float3 b )
{
return float3(
Divide_blend( a.r, b.r ),
Divide_blend( a.g, b.g ),
Divide_blend( a.b, b.b )
);
}
float4 Divide_blend( float4 a, float4 b, bool blendAlpha = false )
{
return float4(
Divide_blend( a.rgb, b.rgb ).rgb,
blendAlpha ? Divide_blend( a.a, b.a ) : max( a.a, b.a )
);
}
";
[Function( "RGB2HSV" )]
public static string RGB2HSV => @"
float3 RGB2HSV( float3 c )
{
float4 K = float4( 0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0 );
float4 p = lerp( float4( c.bg, K.wz ), float4( c.gb, K.xy ), step( c.b, c.g ) );
float4 q = lerp( float4( p.xyw, c.r ), float4( c.r, p.yzx ), step( p.x, c.r ) );
float d = q.x - min( q.w, q.y );
float e = 1.0e-10;
return float3( abs( q.z + ( q.w - q.y ) / ( 6.0 * d + e ) ), d / ( q.x + e ), q.x );
}
";
[Function( "HSV2RGB" )]
public static string HSV2RGB => @"
float3 HSV2RGB( float3 c )
{
float4 K = float4( 1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0 );
float3 p = abs( frac( c.xxx + K.xyz ) * 6.0 - K.www );
return c.z * lerp( K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y );
}
";
[Function( "TexTriplanar_Color" )]
public static string TexTriplanar_Color => @"
float4 TexTriplanar_Color( in Texture2D tTex, in SamplerState sSampler, float3 vPosition, float3 vNormal )
{
float2 uvX = vPosition.zy;
float2 uvY = vPosition.xz;
float2 uvZ = vPosition.xy;
float3 triblend = saturate(pow(abs(vNormal), 4));
triblend /= max(dot(triblend, half3(1,1,1)), 0.0001);
half3 axisSign = vNormal < 0 ? -1 : 1;
uvX.x *= axisSign.x;
uvY.x *= axisSign.y;
uvZ.x *= -axisSign.z;
float4 colX = Tex2DS( tTex, sSampler, uvX );
float4 colY = Tex2DS( tTex, sSampler, uvY );
float4 colZ = Tex2DS( tTex, sSampler, uvZ );
return colX * triblend.x + colY * triblend.y + colZ * triblend.z;
}
";
[Function( "TexTriplanar_Normal" )]
public static string TexTriplanar_Normal => @"
float3 TexTriplanar_Normal( in Texture2D tTex, in SamplerState sSampler, float3 vPosition, float3 vNormal )
{
float2 uvX = vPosition.zy;
float2 uvY = vPosition.xz;
float2 uvZ = vPosition.xy;
float3 triblend = saturate( pow( abs( vNormal ), 4 ) );
triblend /= max( dot( triblend, half3( 1, 1, 1 ) ), 0.0001 );
half3 axisSign = vNormal < 0 ? -1 : 1;
uvX.x *= axisSign.x;
uvY.x *= axisSign.y;
uvZ.x *= -axisSign.z;
float3 tnormalX = DecodeNormal( Tex2DS( tTex, sSampler, uvX ).xyz );
float3 tnormalY = DecodeNormal( Tex2DS( tTex, sSampler, uvY ).xyz );
float3 tnormalZ = DecodeNormal( Tex2DS( tTex, sSampler, uvZ ).xyz );
tnormalX.x *= axisSign.x;
tnormalY.x *= axisSign.y;
tnormalZ.x *= -axisSign.z;
tnormalX = half3( tnormalX.xy + vNormal.zy, vNormal.x );
tnormalY = half3( tnormalY.xy + vNormal.xz, vNormal.y );
tnormalZ = half3( tnormalZ.xy + vNormal.xy, vNormal.z );
return normalize(
tnormalX.zyx * triblend.x +
tnormalY.xzy * triblend.y +
tnormalZ.xyz * triblend.z +
vNormal
);
}
";
[Function( "Quaternion_FromAngles" )]
public static string Quaternion_FromAngles => @"
float4 Quaternion_FromAngles( float3 vAngles )
{
float4 rot = { 0.0, 0.0, 0.0, 1.0 };
const float ANGLE_CONVERSION = 3.14159265 / 360.0;
float pitch = vAngles.x * ANGLE_CONVERSION;
float yaw = vAngles.y * ANGLE_CONVERSION;
float roll = vAngles.z * ANGLE_CONVERSION;
float sp = sin( pitch );
float cp = cos( pitch );
float sy = sin( yaw );
float cy = cos( yaw );
float sr = sin( roll );
float cr = cos( roll );
float srXcp = sr * cp;
float crXsp = cr * sp;
rot.x = srXcp * cy - crXsp * sy; // X
rot.y = crXsp * cy + srXcp * sy; // Y
float crXcp = cr * cp;
float srXsp = sr * sp;
rot.z = crXcp * sy - srXsp * cy; // Z
rot.w = crXcp * cy + srXsp * sy; // W (real component)
return rot;
}
";
[Function( "Matrix_Identity" )]
public static string Matrix_Identity => @"
float4x4 Matrix_Identity()
{
return
{
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
};
}
";
[Function( "Matrix_FromQuaternion" )]
public static string Matrix_FromQuaternion => @"
float4x4 Matrix_FromQuaternion( float4 qRotation )
{
float xx = qRotation.x * qRotation.x;
float yy = qRotation.y * qRotation.y;
float zz = qRotation.z * qRotation.z;
float xy = qRotation.x * qRotation.y;
float wz = qRotation.z * qRotation.w;
float xz = qRotation.z * qRotation.x;
float wy = qRotation.y * qRotation.w;
float yz = qRotation.y * qRotation.z;
float wx = qRotation.x * qRotation.w;
float4x4 result =
{
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
};
result._11 = 1.0 - 2.0 * (yy + zz);
result._21 = 2.0 * (xy + wz);
result._31 = 2.0 * (xz - wy);
result._12 = 2.0 * (xy - wz);
result._22 = 1.0 - 2.0 * (zz + xx);
result._32 = 2.0 * (yz + wx);
result._13 = 2.0 * (xz + wy);
result._23 = 2.0 * (yz - wx);
result._33 = 1.0 - 2.0 * (yy + xx);
return result;
}
";
[Function( "Matrix_FromScale" )]
public static string Matrix_FromScale => @"
float4x4 Matrix_FromScale( float3 vScale )
{
float4x4 result =
{
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
};
result._11 = vScale.x;
result._22 = vScale.y;
result._33 = vScale.z;
return result;
}
";
[Function( "Matrix_FromTranslation" )]
public static string Matrix_FromTranslation => @"
float4x4 Matrix_FromTranslation( float3 vTranslation )
{
float4x4 result =
{
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
};
result._14 = vTranslation.x;
result._24 = vTranslation.y;
result._34 = vTranslation.z;
return result;
}
";
[Function( "Vec3OsToTs" )]
public static string Vec3OsToTs => @"
float3 Vec3OsToTs( float3 vVectorOs, float3 vNormalOs, float3 vTangentUOs, float3 vTangentVOs )
{
float3 vVectorTs;
vVectorTs.x = dot( vVectorOs.xyz, vTangentUOs.xyz );
vVectorTs.y = dot( vVectorOs.xyz, vTangentVOs.xyz );
vVectorTs.z = dot( vVectorOs.xyz, vNormalOs.xyz );
return vVectorTs.xyz;
}
";
public static string TextureDefinition => @"<!-- dmx encoding keyvalues2_noids 1 format vtex 1 -->
""CDmeVtex""
{{
""m_inputTextureArray"" ""element_array""
[
""CDmeInputTexture""
{{
""m_name"" ""string"" ""0""
""m_fileName"" ""string"" ""{0}""
""m_colorSpace"" ""string"" ""{1}""
""m_typeString"" ""string"" ""2D""
""m_imageProcessorArray"" ""element_array""
[
""CDmeImageProcessor""
{{
""m_algorithm"" ""string"" ""{3}""
""m_stringArg"" ""string"" """"
""m_vFloat4Arg"" ""vector4"" ""0 0 0 0""
}}
]
}}
]
""m_outputTypeString"" ""string"" ""2D""
""m_outputFormat"" ""string"" ""{2}""
""m_textureOutputChannelArray"" ""element_array""
[
""CDmeTextureOutputChannel""
{{
""m_inputTextureArray"" ""string_array""
[
""0""
]
""m_srcChannels"" ""string"" ""rgba""
""m_dstChannels"" ""string"" ""rgba""
""m_mipAlgorithm"" ""CDmeImageProcessor""
{{
""m_algorithm"" ""string"" ""Box""
""m_stringArg"" ""string"" """"
""m_vFloat4Arg"" ""vector4"" ""0 0 0 0""
}}
""m_outputColorSpace"" ""string"" ""{1}""
}}
]
}}";
[AttributeUsage( AttributeTargets.Property )]
private class FunctionAttribute : Attribute
{
public string Name { get; set; }
public FunctionAttribute( string name )
{
Name = name;
}
}
private static Dictionary<string, string> Functions;
public static bool TryGetFunction( string name, out string func )
{
return Functions.TryGetValue( name, out func );
}
public static bool HasFunction( string name )
{
return Functions.ContainsKey( name );
}
internal static bool RegisterFunction( string name, string code )
{
if ( Functions.ContainsKey( name ) )
return false;
Functions[name] = code;
return true;
}
static ShaderTemplate()
{
CreateFunctions();
}
[EditorEvent.Hotload]
private static void CreateFunctions()
{
Functions = new Dictionary<string, string>();
var properties = typeof( ShaderTemplate ).GetProperties( BindingFlags.Public | BindingFlags.Static );
foreach ( var property in properties )
{
if ( property.PropertyType == typeof( string ) )
{
var attr = (FunctionAttribute)Attribute.GetCustomAttribute( property, typeof( FunctionAttribute ) );
if ( attr != null )
{
Functions[attr.Name] = (string)property.GetValue( null );
}
}
}
}
}
namespace Editor.ShaderGraphExtras;
public static class PostProcessTemplate
{
public static Dictionary<string, bool> Features => new()
{
{ "SupportsAlbedo", true },
{ "SupportsEmission", false },
{ "SupportsOpacity", true },
{ "SupportsNormal", false },
{ "SupportsRoughness", false },
{ "SupportsMetalness", false },
{ "SupportsAmbientOcclusion", false },
{ "SupportsPositionOffset", false },
{ "SupportsPixelDepthOffset", false },
{ "SupportsLitShadingModel", false },
{ "SupportsUnlitShadingModel", true },
{ "SupportsCustomShadingModel", false },
{ "SupportsOpaqueBlendMode", true },
{ "SupportsMaskedBlendMode", true },
{ "SupportsTranslucentBlendMode", true },
{ "SupportsDynamicBlendMode", true }
};
public static string Code => @"
HEADER
{{
Description = ""{0}"";
}}
FEATURES
{{
#include ""common/features.hlsl""
{1}
}}
MODES
{{
Forward();
Depth();
ToolsShadingComplexity( ""tools_shading_complexity.shader"" );
}}
COMMON
{{
{2}
#include ""common/shared.hlsl""
#include ""procedural.hlsl""
#define S_UV2 1
}}
struct VertexInput
{{
#include ""common/vertexinput.hlsl""
float4 vColor : COLOR0 < Semantic( Color ); >;
{3}
}};
struct PixelInput
{{
#include ""common/pixelinput.hlsl""
float3 vPositionOs : TEXCOORD14;
float3 vNormalOs : TEXCOORD15;
float4 vTangentUOs_flTangentVSign : TANGENT < Semantic( TangentU_SignV ); >;
float4 vColor : COLOR0;
float4 vTintColor : COLOR1;
#if ( PROGRAM == VFX_PROGRAM_PS )
bool vFrontFacing : SV_IsFrontFace;
#endif
{4}
}};
VS
{{
#include ""common/vertex.hlsl""
{5}{6}{7}
PixelInput MainVs( VertexInput v )
{{
PixelInput i;
i.vPositionPs = float4(v.vPositionOs.xy, 0.0f, 1.0f );
i.vPositionWs = float3(v.vTexCoord, 0.0f);
{8}
return i;
}}
}}
PS
{{
#include ""common/pixel.hlsl""
#include ""postprocess/functions.hlsl""
#include ""postprocess/common.hlsl""
{9}{10}{11}
Texture2D g_tColorBuffer < Attribute( ""ColorBuffer"" ); SrgbRead ( true ); >;
float4 MainPs( PixelInput i ) : SV_Target0
{{
Material m = Material::Init( i );
m.Albedo = float3( 1, 1, 1 );
m.Opacity = 1;
{12}
m.Opacity = saturate( m.Opacity );
{13}
}}
}}
";
}
namespace Editor.ShaderGraphExtras;
public struct NodeInput : IValid
{
[Hide, Browsable( false )]
public string Identifier { get; set; }
[Hide, Browsable( false )]
public string Output { get; set; }
[Hide, Browsable( false )]
[JsonIgnore]
public string Subgraph { get; set; }
[Hide, Browsable( false )]
[JsonIgnore]
public string SubgraphNode { get; set; }
[Browsable( false )]
[JsonIgnore, Hide]
public readonly bool IsValid => !string.IsNullOrWhiteSpace( Identifier ) && !string.IsNullOrWhiteSpace( Output );
public override readonly string ToString()
{
var subgraph = (Subgraph is not null) ? ("." + Subgraph) : "";
var subgraphNode = (SubgraphNode is not null) ? ("." + SubgraphNode) : "";
return IsValid ? $"{Identifier}.{Output}{subgraph}{subgraphNode}" : "null";
}
public NodeInput()
{
Identifier = "";
Output = "";
Subgraph = null;
}
public static bool operator ==( NodeInput a, NodeInput b ) => a.Identifier == b.Identifier && a.Output == b.Output && a.Subgraph == b.Subgraph && a.SubgraphNode == b.SubgraphNode;
public static bool operator !=( NodeInput a, NodeInput b ) => a.Identifier != b.Identifier || a.Output != b.Output || a.Subgraph != b.Subgraph || a.SubgraphNode != b.SubgraphNode;
public override bool Equals( object obj ) => obj is NodeInput input && this == input;
public override int GetHashCode() => System.HashCode.Combine( Identifier, Output, Subgraph, SubgraphNode );
}
namespace Editor.ShaderGraphExtras.Nodes;
/// <summary>
/// Current time
/// </summary>
[Title( "Time" ), Category( "Variables" ), Icon( "timer" )]
public sealed class Time : ShaderNode
{
[JsonIgnore]
public float Value => RealTime.Now;
[Output( typeof( float ) ), Title( "Time" )]
[Hide]
public NodeResult.Func Result => ( GraphCompiler compiler ) =>
{
return new NodeResult( 1, compiler.IsPreview ? "g_flPreviewTime" : "g_flTime", compiler.IsNotPreview );
};
}
namespace Editor.ShaderGraphExtras.Nodes;
[Title("SGE - Noise"), Category( "Shader Graph Extras - Universal" ), Icon("grain")]
public sealed class SGENoiseNode : ShaderNode
{
public enum SGENoiseMode
{
Static,
Value,
Simplex,
[Title("fBM")]
fBM,
Voronoi
}
public SGENoiseMode Mode { get; set; } = SGENoiseMode.fBM;
public enum SGENoiseDimension
{
[Title("2D")]
Noise2D,
[Title("3D")]
Noise3D
}
public SGENoiseDimension Dimension { get; set; } = SGENoiseDimension.Noise2D;
[Hide, JsonIgnore]
int _lastHashCode = 0;
public override void OnFrame()
{
base.OnFrame();
var hashCode = new HashCode();
hashCode.Add(Mode);
hashCode.Add(Dimension);
var hc = hashCode.ToHashCode();
if (hc != _lastHashCode)
{
_lastHashCode = hc;
CreateInputs();
Update();
}
}
private void CreateInputs()
{
var plugs = new List<IPlugIn>();
var serialized = this.GetSerialized();
foreach (var property in serialized)
{
if (property.TryGetAttribute<InputAttribute>(out var inputAttr))
{
if (property.TryGetAttribute<ConditionalVisibilityAttribute>(out var conditionalVisibilityAttr))
{
if (conditionalVisibilityAttr.TestCondition(this.GetSerialized()))
{
continue;
}
}
var propertyInfo = typeof(SGENoiseNode).GetProperty(property.Name);
if (propertyInfo is null) continue;
var info = new PlugInfo(propertyInfo);
var displayInfo = info.DisplayInfo;
displayInfo.Name = property.DisplayName;
info.DisplayInfo = displayInfo;
// Try to find existing plug to preserve connections
var oldPlug = Inputs.FirstOrDefault(x => x is BasePlugIn plugIn && plugIn.Info.Name == property.Name) as BasePlugIn;
if (oldPlug is not null)
{
oldPlug.Info.Name = info.Name;
oldPlug.Info.Type = info.Type;
oldPlug.Info.DisplayInfo = info.DisplayInfo;
plugs.Add(oldPlug);
}
else
{
var plug = new BasePlugIn(this, info, info.Type);
plugs.Add(plug);
}
}
}
Inputs = plugs;
}
[Hide]
private bool IsNoisefBMMode => Mode == SGENoiseMode.fBM;
[Hide]
private bool IsNoiseVoronoiMode => Mode == SGENoiseMode.Voronoi;
[Hide]
[Input(typeof(Vector3))]
public NodeInput Coordinates { get; set; }
[Hide]
[Input(typeof(int))]
[ShowIf(nameof(IsNoisefBMMode), true)]
public NodeInput Octaves { get; set; }
[InputDefault(nameof(Octaves))]
[ShowIf(nameof(IsNoisefBMMode), true)]
public float DefaultOctaves { get; set; } = 6;
[Hide]
[Input(typeof(float))]
[ShowIf(nameof(IsNoiseVoronoiMode), true)]
public NodeInput Offset { get; set; }
[InputDefault(nameof(Offset))]
[ShowIf(nameof(IsNoiseVoronoiMode), true)]
public float DefaultOffset { get; set; } = 3.14159265359f;
[Hide]
[Output(typeof(float))]
public NodeResult.Func Output => (GraphCompiler compiler) =>
{
var coordinates = compiler.Result(Coordinates);
compiler.RegisterInclude("shaders/HLSL/Functions/FUNC-noise.hlsl");
NodeResult result = new NodeResult();
switch (Mode)
{
case SGENoiseMode.Static:
if (Dimension == SGENoiseDimension.Noise2D)
{
result = new NodeResult(NodeResultType.Float, $"SGEStaticNoise2D({coordinates})");
}
else
{
result = new NodeResult(NodeResultType.Float, $"SGEStaticNoise3D({coordinates})");
}
break;
case SGENoiseMode.Value:
if (Dimension == SGENoiseDimension.Noise2D)
{
result = new NodeResult(NodeResultType.Float, $"SGEValueNoise2D({coordinates})");
}
else
{
result = new NodeResult(NodeResultType.Float, $"SGEValueNoise3D({coordinates})");
}
break;
case SGENoiseMode.Simplex:
if (Dimension == SGENoiseDimension.Noise2D)
{
result = new NodeResult(NodeResultType.Float, $"SGESimplexNoise2D({coordinates})");
}
else
{
result = new NodeResult(NodeResultType.Float, $"SGESimplexNoise3D({coordinates})");
}
break;
case SGENoiseMode.fBM:
var octaves = compiler.ResultOrDefault(Octaves, DefaultOctaves);
if (Dimension == SGENoiseDimension.Noise2D)
{
result = new NodeResult(NodeResultType.Float, $"SGEfBMNoise2D({coordinates}, {octaves})");
}
else
{
result = new NodeResult(NodeResultType.Float, $"SGEfBMNoise3D({coordinates}, {octaves})");
}
break;
case SGENoiseMode.Voronoi:
var offset = compiler.ResultOrDefault(Offset,DefaultOffset);
if (Dimension == SGENoiseDimension.Noise2D)
{
result = new NodeResult(NodeResultType.Float, $"SGEVoronoiNoise2D({coordinates}, {offset})");
}
else
{
result = new NodeResult(NodeResultType.Float, $"SGEVoronoiNoise3D({coordinates}, {offset})");
}
break;
}
return result;
};
}